Skip to content

Releases: AngeloSha/uchiyomi

v0.28.0: The server can tell you a new version exists

Choose a tag to compare

@github-actions github-actions released this 10 Sep 14:39

The server can tell you a new version exists

Until now it could not: the version lived in package.json and nothing read it at runtime, so the app did
not know what it was, let alone whether anything newer had been published. Admin β†’ Health now has a
Version row, and once a day the server asks GitHub whether a newer release is out.

It is a plain read of a public releases page β€” the same one you could open in a browser. Nothing about
your server is sent.
GitHub sees an IP address, as it would for anyone loading a public page, and that is
all. Being a version behind is never treated as a fault and will not turn anything amber; an update notice
that cries wolf is one people learn to ignore.

On by default, with a switch in Settings. Off means no request is made at all, and the row says so rather
than quietly claiming you are up to date. If GitHub cannot be reached, it says that too β€” "up to date" and
"we could not ask" look identical on a page, and only one of them is a reason to relax.

And, if you want, it can be counted

Nobody can see how many people self-host this. That is the point of self-hosting, and it also means nobody β€”
including whoever wrote it β€” knows whether a release reached twenty people or two hundred.

So there is now an opt-in install count, off by default, as a separate switch. Turn it on and once a
day your server sends this, and nothing else, to uchiyomi.com:

{ "id": "3f2a…", "month": "2026-09", "version": "0.28.0",
  "arch": "arm64", "layout": "aio", "db": "embedded" }

The settings page shows you that exact object before you agree to it β€” not a description of it, the literal
thing, built by the same code that sends it, so the two cannot drift apart.

  • The id changes every month. It is a hash of a secret that never leaves your server plus the current
    month. Two pings in one month count as one install; two pings in different months cannot be linked to each
    other β€” not by us, and not by anyone who obtained the data, because the secret is not in it.
  • No library, no titles, no accounts, no address, no hostname. A test fails the build if a field is ever
    added to that payload, because a field added quietly is a field you were never shown.
  • The collector stores no IP and no clock time, only the UTC date, and access logging is off for that
    endpoint so there is no side channel that re-identifies a row. Anything unrecognised in a ping is dropped
    rather than stored. Months older than a year are deleted.
  • Turning it off destroys the secret and asks the collector to forget the current month. Turning it back
    on later makes a new id β€” it cannot resume the old one, which is the honest behaviour even though it means
    a returning install looks like a new one.
  • GET https://uchiyomi.com/api/hello shows the running totals, so you can see what your ping became.

The two switches are deliberately separate and point at different servers. If the update check went to a
server this project runs, that server could count installs from its access log whether or not anyone
consented, and "updates on, counting off" would be a setting that did nothing. Keeping them apart is what
makes the off position real, and there is a test that fails if they ever converge.

UCHIYOMI_PING_URL repoints the count at your own collector, or disables it outright if set empty.

v0.27.0: One Library, and its filters finally organised

Choose a tag to compare

@github-actions github-actions released this 09 Sep 16:39

One Library, and its filters finally organised

Browse was a second Library. /browse?genre=Horror ran the same search over the same collection and drew it
in the same grid as /library?genres=Horror β€” the only thing it had of its own was the wall of genre tiles.
So the tab is gone, and the wall's useful half has moved to where you were going to end up anyway.

The Library's own controls had drifted into a pile: three horizontally-scrolling rows of chips, one of them
seven wide, mixing four sort options with a Filters button, an 18+ toggle and a Select toggle β€” nothing
saying which were sorts and which were filters. Behind the Filters button, the genre list was a flat wall of
ninety-three unsorted, uncounted, unsearchable words.

All of it now lives in one panel with labelled sections β€” Sort by, Library, Read state,
Status, Format, Genres. On a laptop the panel sits down the left of the grid and stays there. On
a phone it opens as a proper sheet: one Filters button instead of a row of seven chips.

What came across from Browse:

  • Genres are counted and ranked, biggest first, each with a small mosaic of covers from that shelf β€” the
    part of the old tile wall that made it worth looking at, at a size that suits a list.
  • Formats stay separate from genres. Manhwa covers 161 of the 2,132 series on the library this was built
    against, so ranked by size it outranks every actual genre while saying nothing about what a book is like.
  • Surprise me, the random-series button, is now beside the Library title.
  • A search box over the genres, so ninety-three of them is a list rather than a wall.

Two things fixed on the way past:

  • The genre list used to come from an endpoint that returns genres exactly as they are spelled, while the
    filter matches them case-insensitively. That is 100 chips for 93 genres: "Martial arts" and "Martial Arts"
    appeared as two, and either one returned the same series. They are now one.
  • Picking a library used to filter the grid while the Filters badge said nothing was filtered, because the
    library tabs were counted as navigation rather than as a filter. Now everything that narrows the shelf
    counts, and Clear all clears all of it.

The publication statuses β€” Ongoing, Completed, Hiatus, Cancelled β€” were being title-cased in code rather
than translated, so they read in English in all eight languages. They are translated now.

Large displays have been a column short this whole time

Found while measuring the new layout, and older than it. Five cover grids β€” library, search (twice),
discover and the admin picker β€” each ended with a step like min-[1800px]:grid-cols-10, and not one of
them had ever applied
. Tailwind emits that kind of breakpoint before its own named ones, so on a 1920px
display the earlier 2xl rule came later in the stylesheet and won.

Nothing failed, because a grid one step short of its own source code still looks like a grid. Measured in a
browser at 2560px: seven columns of 314px covers where the class list asked for ten.

The extra breakpoints are now declared properly, and a test refuses any responsive step that a later rule
would override. It found the fifth grid on its first run.

The last of the MangaRead covers

Different fault from v0.26.3, and this one was doubling the listing.

The listing parser reads the same series link out of three different pieces of markup, and one of the three
patterns dropped the trailing slash from the URL while the other two kept it. On any site that writes the
slash β€” MangaRead does β€” the check for "have I already seen this series?" never matched. Every series came
back twice: once properly, and once more named after its alt text and carrying no cover at all.

Measured against a live MangaRead listing: twelve series parsed as twenty-four. Because a source page keeps
the first 24 results, that also means half of what MangaRead offered on Discover was a copy of the other
half. The proper entries came first and the blank ones after, which is why it looked like a few missing
covers rather than a doubled list.

The three passes now agree on the key. The URL a series is stored under is untouched, so nothing already
in a library is affected. This also fixes the reverse case, which nobody had reported: a site writing the
slash on its headings but not its thumbnails loses every cover rather than half of them.

It hid for so long because every fixture in the test file wrote URLs without a trailing slash β€” the one
shape that breaks was the one shape the tests never used. It is now asserted in both directions.

v0.26.3: The rest of the grey covers, and why they broke on their own

Choose a tag to compare

@github-actions github-actions released this 09 Sep 11:21

The rest of the grey covers, and why they broke on their own

v0.26.2 fixed the covers that came from the extension engine. The ones that remained β€” some of a source's
covers working and others not, on the same page β€” were a different fault with a much more ordinary cause.

Sites that load images lazily put a spacer in the src attribute and the real picture in data-src. The
code that reads a cover out of that markup was written as one regular expression listing both attributes,
which reads as a preference and is not one: which attribute wins is decided by regex mechanics and by the
order the site happened to write them in. Two different spellings of that expression were in use, in
different parts of the code, and they failed on opposite attribute orders.

So a cover was right or wrong depending on nothing but markup order β€” which is the whole answer to why
covers that worked for months stopped without anything changing here: the site reordered its markup, and the
extraction quietly flipped. It is also why only some series were affected rather than all of them.

There is now one place that answers "which attribute holds the picture", and it answers by preference:
data-src, then the other lazy attributes, then srcset, and src only as a last resort. Every engine uses
it. The same fault was present in the code that reads page images, where it would have served placeholder
pages rather than placeholder covers β€” nobody had hit it yet.

A picture repeated on every card is not a cover

When a listing comes back with the same image on three or more different series, that image is a placeholder
and the covers were not parsed. Rather than show one picture twenty times β€” which is a confident lie β€” the
cover is dropped, and the fallbacks that already exist take over: the artwork from AniList for a series in
your library, then its first downloaded page, and otherwise the app's own empty tile.

Sources are now checked for this, not just for whether they answer

The daily source check already fetched a listing and a series page and looked only at whether they returned
anything. It now also compares them: when the two disagree about the same series' cover, one of the two
parsers is wrong, and that is exactly the fault above β€” visible without waiting for somebody to notice grey
tiles. It also notices one image repeated across a listing, and a listing that has lost its covers entirely.

Reported in Admin β†’ Health, never as a failure: a source whose covers are wrong still fetches and reads
perfectly well, and marking it broken would turn a cosmetic fault into an outage.

Security

Two Dependabot advisories, both about zip extraction following symlinks, neither with a published fix.
Assessed rather than ignored: one is a test-only dependency that never reaches a running server, and the
other is used here solely to build archives β€” the vulnerable extraction call is never made, and untrusted
archives are read by a different library entirely. A test now enforces that second claim, so if extraction is
ever added, it fails rather than quietly making the assessment untrue.

v0.26.2: Discover's grey covers

Choose a tag to compare

@github-actions github-actions released this 09 Sep 09:04

Discover's grey covers

Whole rails of Discover showed a grey box with a broken-image icon instead of cover art β€” every result from
an affected source, permanently. The cause is a fix colliding with a design.

v0.21.0 hardened the cover proxy so it could not be pointed at anything on the local network, because the URL
it fetches is supplied by whoever asks. Separately, the extension engine serves every cover through itself,
so an extension source's cover lives at the engine's own address β€” which is on the local network. The guard
did exactly what it was written to do, to the app's own engine, and the result was served as a grey
placeholder and then cached under the real cover's key with a one-year lifetime.

The proxy now recognises the one address it is configured to talk to and fetches covers from it, exactly as
the extension-icon route already did. That is a single origin, matched whole β€” not a rule about private
addresses, which would hand back the capability the original fix removed.

Two more things were wrong in the same place, and both outlived the cause:

  • A failure was cached as though it were the picture. A cover that could not be fetched wrote its grey
    stand-in under the real cover's key, marked immutable for a year, with nothing able to clear it. One bad
    minute on a source's CDN β€” or a single hiccup from a name server, which the guard cannot tell apart from a
    blocked address β€” meant a grey tile until the cache overflowed. Placeholders are no longer stored, and
    expire in a minute.
  • The grey already on your screen would have stayed. Browsers keep those year-long copies by address, so
    the address changed too, and the server-side entries written under the old scheme are now unreachable.

The Cloudflare solver says when it is behind, and speaks up when it dies

The solver announces its version and the app printed it and compared it to nothing. Admin β†’ Health now says
when a newer release is out. It is advice, never an alarm: if GitHub is unreachable, rate-limited, or returns
something unfamiliar, the app has no opinion rather than a problem β€” a health page that can fail because a
third party is having an afternoon is worse than no version check at all.

The bigger gap was that the solver's health was only ever examined when somebody opened the Health tab. A
solver that died at two in the morning stayed dead until it was noticed, while every Cloudflare-protected
source failed and blamed itself β€” the exact confusion that check exists to clear up. It now runs hourly and
notifies on a change, in both directions, so a recovery is reported too and a long outage does not become
hourly noise.

v0.26.1: The background jobs keep up now

Choose a tag to compare

@github-actions github-actions released this 08 Sep 20:22

The background jobs keep up now

Fingerprinting ran once, five minutes after the server started, and never again. Nothing else asked for
it either β€” not a library scan, not the updater sweep, not adding a series from Discover β€” so every chapter
downloaded after that single pass went unprocessed until the container happened to restart. A server that
simply stays up was the worst case, which is exactly backwards.

The effect was invisible because it looks like nothing: the reader treats an un-fingerprinted page as an
ordinary page, so the feature just quietly stopped applying to anything new. Measured on a real library the
day it shipped: 22 chapters, all added after that morning's boot, still untouched hours later, with 50 to 80
more arriving daily.

Both backfills now re-check every six hours, re-arming after each pass β€” including a pass that failed, since
a job that stops rescheduling because one batch went wrong is the same bug wearing a different hat. The long
first delay stays: page fingerprinting decodes every page in the library and should not compete with a server
that has just booted.

The same fault was in the older chapter-fingerprint job, which feeds folder rematch. Fixed alongside.

Marking a page by hand no longer switches the feature off for that chapter

Marking a page as repeated wrote a row that made the chapter look already-processed, so it was dropped from
the queue for good β€” its other pages were never fingerprinted and the automatic rule never ran there again.
Marking one advert turned detection off for the whole chapter, and it was most likely on a new series,
where the backlog is exactly the chapters being opened.

A chapter is now recorded as looked-at only when it has actually been looked at.

Admin β†’ Tasks shows the backlog

The number of chapters still waiting was calculated, sent to the browser and then discarded, so a job that had
quietly stopped picking up work looked identical to one with nothing left to do. Each task now shows how many
items are outstanding, and the schedule reads honestly instead of claiming the job runs once.

v0.26.0: A repeated page folds down instead of disappearing

Choose a tag to compare

@github-actions github-actions released this 08 Sep 18:04

A repeated page folds down instead of disappearing

v0.25.0 removed the pages that are not the story β€” the credit page, the advert β€” from the chapter you were
reading. That was the wrong shape, and reading with it for a day made the reason plain: a chapter was quietly
shorter than it really was, a floating chip announced the fact at every chapter start whether you cared or
not, and there was no way to see what was going to be removed before it went.

Now the page stays exactly where it is, drawn as a thin band of itself with a label. You scroll past it in an
instant, or tap it to open it in place and tap collapse to fold it away again. The band is a slice of the
real page, so you can see it is the credit page rather than take our word for it β€” which is the part that was
missing. The floating chip is gone: the notice sits where the page is, which is both harder to miss and
impossible to mistake for a comment about something else.

Repeated pages in reader settings now offers Show all, Collapse (the default) and Hide. Hide is
the old behaviour for anyone who wants the page gone outright, chip and all. Reading page-by-page rather than
scrolling, Collapse still removes β€” a slide is one whole page wide, so there is no room for a band, and in
that mode an unwanted page costs a swipe rather than a scroll anyway.

Four bugs that removal had been causing

Putting the page back in the list the reader counts with fixed a set of failures that all had the same root:
while pages were being removed, a position in the chapter and a page number were two different things, and
several places assumed they were the same.

  • Resume and saved Moments landed late. Opening a Moment saved on page 3 took you to page 4 β€” one page
    further on for every repeated page earlier in the chapter. Silently, because arriving a page on is
    indistinguishable from having read that far.
  • The chapter divider vanished when a chapter opened on a credit page, taking the "Up Next" heading with
    it β€” and because the same marker keeps a chapter's first page unpaired, every double-page spread in that
    chapter was shifted by one.
  • Tapping a dimmed tile in the page grid scrolled to the top of the entire library instead of to the page
    you tapped.
  • A chapter that was entirely furniture disappeared, and continuous reading walked from the chapter
    before it to the chapter after with nothing in between.

The reading flow is now built in one place, web/lib/readerFlow.ts, where it can be tested β€” which is why
these were reachable at all. Each has a test that fails when the old behaviour is put back.

v0.25.2: Blank slices were being skipped as if they were the same page

Choose a tag to compare

@github-actions github-actions released this 08 Sep 16:42

Blank slices were being skipped as if they were the same page

The fingerprint asks, sixty-four times, whether a pixel is brighter than the one to its right. So the only
variation it can see is variation across a row. The guard meant to refuse featureless pages measured something
subtly different β€” the brightest and darkest pixel anywhere in the page.

Those come apart on exactly the kind of page a long-strip webtoon is full of. A slice that fades from black at
the top to white at the bottom has the widest possible range, 255, and sails through a guard asking for 8 β€” while
every left-to-right comparison on it is a tie. All sixty-four answers come back "no", the fingerprint is all
zeros, and every such slice in the library carries that same fingerprint. They were being matched to each other
and skipped.

This was not theoretical. On a real 42,000-chapter library the all-zero fingerprint alone was hiding 100 pages,
and in one series 59 of the 166 skipped pages were this. The guard now measures what the fingerprint actually
reads, and a page with no left-to-right variation is refused, as was always intended.

Fixing the guard is not enough by itself, because fingerprints already recorded were written by the old one and
the background job never revisits a chapter it has seen. So the same rule is applied where the matching happens:
a fingerprint in which almost every comparison was a tie is no longer accepted as evidence that two pages are the
same page. That takes effect immediately, without re-reading anything.

Some genuinely repeated near-blank separators stop being skipped as a result. They are blank slices, so this
shows up as a sliver of nothing rather than a missing panel β€” and fewer skips is the direction this feature is
meant to be wrong in.

v0.25.1: The page-hash job could never finish

Choose a tag to compare

@github-actions github-actions released this 08 Sep 14:26

The page-hash job could never finish

Found by watching v0.25.0 run against a real library, which is the only place it shows.

The job picks its next batch by asking for chapters that have no page fingerprints yet, and it writes a row
per page. A chapter that yields no pages β€” an unreadable archive, an empty one, a file that has since been
moved β€” therefore wrote nothing, and so was still "not looked at yet" when the next batch was chosen. Working
chapters get their rows and drop out; broken ones accumulate. The moment they are all that is left, the loop
has nothing to exhaust and spins on them, at full CPU, forever.

One such chapter in a library is enough, which on any library of real size is close to a certainty.

A chapter that produced nothing now records that it was looked at, so it drops out of the queue like any
other. That mark is inert everywhere else: it carries no fingerprint, so it can never match another page, and
it is never offered to the reader as a page to skip.

A chapter is never mostly skipped

The same first real run turned up the failure this feature is least allowed to have. Across seven thousand
fingerprinted chapters the average chapter had 1.4 pages of furniture and under 6% of all pages were
flagged β€” but a few hundred chapters wanted to skip a third or more of themselves, and the worst wanted 68
pages out of 88.

Those are duplicate and phantom chapters, where the same file is filed under several chapter numbers. Every
page then genuinely does recur across chapters, so the arithmetic is right and the conclusion is nonsense.
Nothing inside the rule can tell that case apart, so the chapter's own shape is the check: if more than a
third of a chapter is about to be skipped, the automatic decision is thrown away and the chapter reads
exactly as it always did. Fewer skips, which is the direction this feature is always wrong in.

A page you marked by hand is never subject to that cap. It is the one input that is not arithmetic, and the
entire point of it is that it outranks the rule.

v0.25.0: The pages that are not the story

Choose a tag to compare

@github-actions github-actions released this 08 Sep 12:28

The pages that are not the story

Every chapter of a scanlated series opens with the same credit page. Some carry an advert, or a "read the
rest at…" splash. You swipe past them, chapter after chapter, and they are the single most repetitive thing
about reading here. No manga reader does anything about this β€” the closest thing in any adjacent product is
Jellyfin skipping a TV intro.

Uchiyomi now finds them and skips them, and the way it decides is deliberately dull. A credit page is the
same image in every chapter of that series
, so a page whose fingerprint turns up in three or more chapters
is furniture. That is arithmetic, not a guess about what a page looks like: story pages are not the same
picture twice. Three chapters and not two, because two chapters sharing a title card is a coincidence, and
two is the commonest state of a part-downloaded series.

It only ever compares chapters within one series, even though the same group's credit page across different
series would be stronger evidence still. Flagging across series means a page could be hidden in a book whose
chapters nobody ever compared, and missing a few skips is a far better failure than that.

Nothing is ever hidden without saying so. A skipped page leaves a quiet chip β€” skipped 1 repeated page β€”
show
β€” that puts it back with one tap, and the page grid still lists every page, dimmed and labelled, so the
chapter you see is never secretly shorter than the chapter you have. You can mark a page as junk by hand, or
rescue one it got wrong; either decision is permanent and outranks the arithmetic in both directions, which
is what makes skipping safe to leave on by default. It is a switch in reader settings if you would rather it
did not. Pages are fingerprinted by a background job, alongside the other library jobs in Admin β†’ Tasks.

One honest limit: a chapter you downloaded before its pages were fingerprinted keeps the flags it was saved
with, until you download it again.

Things this app claimed that were not true

Four of them, found by reading our own documentation against our own code.

Push notifications are listed as a feature; they need a pair of keys that only the developer setup script
ever generated, so on a normal install the button was not missing-with-a-reason, it was simply absent. The
server now generates and keeps those keys on first boot, exactly as it already did for its signing secret.

docs/CONFIGURATION.md said .env.example was the authoritative list of settings. Twenty-one of twenty-three
were not in it, including the ones most worth touching on a small server β€” how many series a sweep may check,
the free-space floor, how long a Cloudflare-protected source is allowed. They are all there now, with their
real defaults.

The v0.6.0 changelog announced that renaming a folder no longer loses your series. That code ships switched
off and appeared in no example file. It is documented now, off by default, with its report mode explained.

And a badge added yesterday, showing which series you have downloaded, went stale the moment you downloaded
another β€” the function that refreshes it had no callers. Mine, from the day before.

Thanks

@hawwwwwk again, for #32:
docs/INSTALL.md still described a database container that has not existed since v0.18.0. Second time he has
caught stale install docs.

v0.24.0: the app opens on a plane

Choose a tag to compare

@AngeloSha AngeloSha released this 07 Sep 18:05

The app opens on a plane

Launching the installed app in airplane mode showed the sign-in screen, with the chapters you had downloaded
for exactly this sitting on the device, unreachable. Tapping a chapter inside an already-running app has
worked since v0.20.0; a cold start never has, and the code said so β€” the browser tests carried a note calling
it out of scope, and v0.20.0's own changelog admitted it.

Two things were being thrown away. The session check could not tell "the server rejected you" from "there is
no server to ask": both came back as a plain no, and a plain no means sign in. And the signed-in account was
remembered only in memory, while every downloaded chapter is filed under whose it is β€” so even past the
sign-in screen the reader would have found nothing, which is a worse failure, because it reads as though the
downloads are gone.

Now a device that had a session keeps it when the server is simply unreachable, and opens on your Downloads
with a banner naming the account. The reader works exactly as it does in a tunnel today. Everything needing
the server is dimmed rather than hidden, because there is nothing behind it until you reconnect β€” and the
moment you do, it checks in, clears the banner and sends up whatever you read.

The part that took the most care is the part nobody sees. This is a multi-user app and household devices get
shared, so: signing out ends it immediately β€” the next offline launch asks for a password and lists nothing,
though the files stay on disk and become readable again when that account signs back in. If the server ever
answers that the session is gone, the device signs itself out. The grace expires exactly when the login
itself would have, which the server now tells the app rather than the app assuming. And nothing new is
stored that could serve as a credential: the record says who you were, not how to prove it.

Right-to-left manga also read its double-page spreads in the wrong order offline. The downloaded chapter had
carried the reading direction all along; the reader threw it away and left a comment saying the information
was not available, one field from where it was.

A README the size of its category

504 lines and 4,797 words, against a median of 132 and 743 across Komga, Kavita, Mihon, Stump, Suwayomi and
Audiobookshelf. The install section alone was longer than five of those six READMEs in their entirety, and
44 lines of it warned about upgrade problems from v0.9.0 and earlier β€” fourteen releases ago, and already
written down in this file.

It is 141 lines now. Almost nothing was deleted: the platform-by-platform install moved to docs/INSTALL.md,
the environment variables to docs/CONFIGURATION.md, and the comparison against other readers to
docs/COMPARISON.md, where a table making dated claims about five moving projects is less likely to be the
second thing a visitor reads.