Skip to content

v0.48.0: every open issue and pull request, in one release - #110

Merged
AngeloSha merged 15 commits into
mainfrom
release/v0.48.0
Sep 26, 2026
Merged

AngeloSha merged 15 commits into
mainfrom
release/v0.48.0

Conversation

@AngeloSha

Copy link
Copy Markdown
Owner

Every open issue and pull request as of 2026-09-26, in one release.

What Where
#109 Downloaded chapters that never reached the library: one folder the scan could not index ended the whole pass, silently, on every run — and the download root is walked second. The scan now steps over it, logs it, and names it under Health → Library scan; a Fetch whose chapter is on disk but unindexed says so; NUL and other XML-forbidden characters are stripped from ComicInfo on write and read; a folder with a deleted twin goes to the live row. 8b0a38d
#102 Merged #105 (reading direction per series + the profile's direction reaching pinned titles). CodeQL test-stub fix on top. 880223c, 931d0d8
#108 Merged: a lost refresh answer is recovered instead of signing the device out. Capped at a day on top (REFRESH_RECOVER_MS), so a copy of an old cookie cannot take a session over for as long as the real device stays away. used_at moved into the v0.48.0 migration block. 58dfbac, 7bb0660
#107 Merged: the flaky repeated-page e2e check (a signed-out tab after a lost refresh). e4ef95c
#101 Admin attention marker + once-per-finding-set banner; GET /api/admin/health/summary reads a stored summary, never runs the checks; six-hourly server run. 68d80a2
#100 Right-click / long-press / Shift+F10 menus: ContextMenu primitive, series card menu, the series page's two ⋯ popovers moved onto it. Per-device switch. 611b8ef
#96 Superseded: undici 8.11.2 (fixed the HTTP/2 gzip break upstream; globalFetch.test.ts passes, MangaDex answers) + the group's swagger/tsx bumps. 9c3b510

Verified locally: bff full suite 1953/1954 (the one: a guard asking for the new scheduler to be listed — done in d66acc9), web 508/508, browser e2e 72/72 with no dropped sessions, #105's walk48 24/24, a v0.48.0 walk of #109→#101 and #100 in Chrome (menus by right-click, keyboard, touch; banner and marker), and v0.47.1 starting on the upgraded database. Each new guard was checked by reintroducing its bug.

Merged by fast-forward push once green; #105, #107 and #108 then show as merged.

🤖 Generated with Claude Code

AngeloSha and others added 15 commits September 25, 2026 19:34
…ght to left (#102)

`seriesDto` answered `readingDirection: 'WEBTOON'` for every series, so the reader's "Series default" never
laid a page out right to left, a right-to-left double spread was never put back together, a downloaded
chapter carried WEBTOON offline, and the Komga-compatible API reported every manga as a webtoon. All four read
that one field; it is now the series' own.

Stored (v0.48.0 block at the end of the DDL): `lib_series.reading_direction` (one of Komga's four, NULL =
unknown, which still reads as WEBTOON) with `reading_direction_from` naming the evidence, and the admin's
choice on `series_overrides.reading_direction`, which beats all of it like the other overrides.

Learned from, most trusted first, and a weaker signal never overwrites a stronger one (learnDirection):
- ComicInfo `<Manga>YesAndRightToLeft</Manga>`, read by every scan. `No` is deliberately not read as left to
  right: tools write it by default, and at the top rank it would pin a Japanese series left to right.
- The source: MangaDex's `originalLanguage` (ja -> RIGHT_TO_LEFT, ko/zh -> WEBTOON), carried on
  `SourceSeries.readingDirection` and recorded when a series is added.
- AniList's `countryOfOrigin`, from the art match every add and art job already makes (one more field in the
  same query). A title SEARCH answers with its best guess whatever it is asked -- a series called "No
  Direction" matched "Dear Green: Hitomi no Ounowa", from Japan -- so the country counts only when one of the
  entry's titles is the series' title (directionFromAniListMatch); a tracker link a person made is trusted.
- Series added before any of this: the nightly repair's new eighth step, `directions`, asks MangaDex (100 ids
  a request) and AniList (50) about series nothing has spoken for, at most REPAIR_DIRECTIONS_MAX (500) each.

Admins set it in Edit details ("Automatic -- Right to left, from the chapter files" says what automatic
means and why) and on `PUT /api/admin/series/:id/meta`, where absent leaves it, null clears it, and an unknown
value is a 400. `GET /api/series/:id` gives admins `detectedDirection`.

Tests: directionSignals.test.ts (mappers, the title agreement, both batch lookups) and
readingDirection.int.test.ts (scan, precedence, override, add, repair, and the reader/manifest/Komga surfaces),
each guard proven by reintroducing its bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ht-to-left page turns cross chapters (#102)

Pinned series settings swallowed the profile's direction. Since v0.46.0 (c575523, merging #95) any change of
mode, theme or spread in the reader's sheet pinned `pagedDirection: n.pagedDirection` to the open title --
usually `series`, inherited from the profile -- so Profile -> Settings -> Reading direction never reached that
title again, and its sheet showed "Series default" whatever the profile said.

- seriesPinChange: a look change pins the look (mode, theme, spread) as before, and the direction only when
  the change WAS the direction, marked `directionChosen` so a deliberate "Series default" for one title
  survives a profile change.
- normaliseSeriesPrefs, on every read of a title's memory: a stored `series` without the mark is what those
  builds wrote by accident and is ignored, locally and when it arrives from the account; the cleaned memory
  is what the next save pushes back. A stored `ltr`/`rtl` is kept -- it may be the default of the day, but it
  is also what choosing a direction for one title wrote then, the very workaround #102 describes.
- withTitleLook: the reader's overlay of a title's memory, extracted so the whole chain has a unit test.

Found while verifying the above in a browser: on a right-to-left track, turning a page as the next chapter
was appended jumped fourteen pages into it (one page when only the Up Next card was added). Chrome keeps an
in-flight smooth scroll's destination in physical pixels from the left, and an RTL track grows leftwards; a
still track keeps its place. Reachable since v0.46.0 by choosing Right to left, and the default for every
Japanese series once "Series default" works. The reader now appends to a right-to-left track only once it has
been still for 250 ms (untilStill); left-to-right tracks are unchanged.

web/test/e2e/walk48.mjs (npm run test:e2e:v048) drives all of it in Chrome against an instance of its own,
with its fixtures written by `seed.py <lib> --rtl` so the main e2e library is untouched. USAGE.md describes the
setting as it now behaves, the Edit details field and the repair step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"a chapter with a repeated credit page collapsed nothing" failed 3 full runs of 5 on 2026-09-25 while the
feature itself was fine. It was never the preference. Instrumented, the failing run's reader tab was a sign-in
page: no aria-labels at all, `junkPages: collapse` in localStorage, `{}` in /api/settings.

The session died a minute earlier. Puppeteer reloads the page whenever `isMobile` or `hasTouch` changes, and
`reload()` resolves on `load`, before the app boots. The app's first act is POST /auth/refresh, which rotates
the refresh cookie. The phone block switched back to 1440 and navigated at once, abandoning that request after
the server had rotated the token but before its Set-Cookie reached the jar. The jar kept the old token, the
60 s rotation grace (REFRESH_GRACE_MS) kept answering it without rotating, and the first refresh after that
minute was refused and cleared the cookie. Whichever tab booted next was signed out: the repeated-pages tab
(fail) or, a few seconds later, the deep link (recovered by ensureSignedIn, so the run passed). The same
rotation was lost in every run examined, passing or failing; pass or fail was a matter of seconds.

- setViewportSettled(): every isMobile/hasTouch switch waits until the refresh request the RELOADED document
  sent has finished before anything navigates (not any refresh response: one the old document had in flight
  can be answered after the reload commits). Driven alone, 8 switches left 7-8 of 16 refreshes unanswered and
  the session gone 62 s later; settled (about 100 ms each), none, and still signed in.
- The check sets what it depends on instead of inheriting it: `junkPages: collapse` and the series' mode
  `vertical` on the server AND in localStorage (the reader adopts /api/settings and the server copy wins),
  written before the tab opens so the reader loads once, not twice (the second navigation could abandon the
  first one's refresh the same way). A signed-out tab signs back in and says so instead of being judged.
- A failure now says what the reader was doing: signed out, mode, page, the preference it saw, the labels.

The assertion is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /auth/refresh rotates yomi_rt. When the browser abandons that request
after the server has rotated -- a reload or navigation while the boot
refresh is in flight, a closed tab, a dropped response -- the jar keeps the
superseded token. The grace window answered it for 60 s and then refused
it, so the next refresh (a tab switch was enough) signed the device out.
Reproduced in a headful Chrome with real keypresses: F5 twice 200 ms apart
over a 200 ms round trip, or with DevTools' Slow 4G preset; not on a LAN.

planRefresh judges a superseded token by what happened after it in its
session's chain, using a new refresh_tokens.used_at (the holder presented
it and got the next one):

1. chain ends in a revoked token -> refused, before the grace, so
   sign-out-everywhere and an admin revoke end a session at once;
2. superseded < 60 s ago -> the race answer, cookie untouched (as before);
3. superseded by a recovery before its holder used it, after the grace ->
   refused and the session ended: two jars hold one session;
4. a later token was used -> old token, refused (as before);
5. nothing later ever used, newest token past the grace -> recovered: the
   head is superseded by a new token, which is set. It inherits the
   head's expiry, so a stale token never extends the session.

Rotation is now one statement (the insert happens only if the revoke did),
so two concurrent refreshes can no longer both insert and fork a device into
two live sessions. Logout walks forward from the presented token, so signing
out from a jar that holds a superseded token ends the session too.

refreshLost.int.test.ts mounts the real auth routes; 6 of its 7 subtests
fail on main on their named assertions, and each of seven single mutations
of the fix fails exactly its own subtest. refreshRace's "older than the
grace window" test never presented the successor -- a lost answer -- so it
now uses it first, keeping its intent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…string-sanitization)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#108 lets a token that was rotated away recover the session once nothing newer
in it was ever used -- which is what keeps a device signed in when a refresh
answer is lost. Unbounded, it also let a copy of an old cookie (a backed-up or
synced browser profile) take the session over for as long as the real device
stayed away. A lost answer is redeemed by the device's next refresh: minutes
while the app is open, the next launch otherwise. REFRESH_RECOVER_MS caps the
recovery at a day; past it the device signs in again, as it did before #108.
Reintroduced by dropping the check: "a lost answer older than a day is not
recovered" gets a 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dependabot #96 brought undici 8.11.0, which left gzip bodies undecoded for
Node's built-in fetch over HTTP/2 -- every built-in source came back empty.
8.11.2 passes bff/test/globalFetch.test.ts and MangaDex answers normally with
it loaded (24 latest, 7 search results), where 8.11.0 still fails the test.
Also the rest of #96's group: @fastify/swagger 9.9.0, tsx 4.23.15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two Unraid installs reported chapters on disk that never reached the library:
a Fetch showed "Fetching 1 chapters" for a second and added nothing, a
series added from Discover downloaded but never appeared, a manual Library
Scan changed nothing, and nothing was logged -- while the same files moved
into the library folder were picked up at once.

persistScan ran every folder in one pass with no isolation, and every caller
swallowed its throw (`persistScan().catch(() => {})`). One folder it could not
index ended the pass on every run, and the downloads root is walked second,
so every chapter this server fetched stayed unindexed. A Fetch then found the
file already there, skipped it, and ended `done` in a second.

- persistScan steps over a folder that throws (its transaction rolls back),
  logs it, and reports it; Admin -> Health gets a Library scan check naming
  each folder and the scanner's reason.
- A NUL in a ComicInfo field is one real way to get there: Postgres refuses
  it in any text value, and the downloader copied a source's description into
  the file verbatim. XML-forbidden control characters are stripped on read
  and on write.
- A folder with a deleted twin in another library goes to the live row: the
  lookup was unordered, so the deleted one could come back first and the
  folder was skipped for good.
- A Fetch whose chapter is on disk but still not in the library after the
  scan says so on its card instead of ending quietly.
- Scan errors are logged at every call site.

Each guard was checked by reintroducing its bug: scanResilience.int.test.ts
fails the named test every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#101)

@Squeaks72's proposal. Admin -> Health only speaks to somebody who already
decided to open it, and what it finds -- a source blocked for a week,
chapters failing their retries, folders the scan cannot index -- goes
unnoticed for exactly that reason.

- While the last report found something, an admin's top bar shows a mark
  beside the Updates bell (amber, red for a problem) whose tooltip is the
  worst check's own sentence.
- A one-line banner says it once per finding set, at every screen size, with
  Take a look / Not now. The key is WHICH checks found something and how
  badly, so a count moving inside one check does not bring it back; never on
  the admin console itself.
- The header never runs the ten checks: GET /api/admin/health/summary answers
  from server_settings.health_summary, written by the Health route whenever
  it runs and by the server every six hours.
- Admins only: the route is behind requireAdmin, and nothing renders for
  other accounts.

Also moves #108's refresh_tokens.used_at into the v0.48.0 migration block, at
the end of the DDL where each release's columns go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Squeaks72's proposal, built as its own first slice: one menu primitive, the
series card menu, and the series page's two hand-rolled ⋯ popovers moved
onto it as its first callers.

- components/ContextMenu.tsx: one menu opened four ways -- right-click, a
  press-and-hold on a touchscreen (with a movement threshold, and the click
  its release would send swallowed), the ⋯ button, Shift+F10 / the Menu key.
  Portalled to <body>, flips at the viewport edges, closes on scroll, resize
  and a press elsewhere; role="menu" with arrow keys, Home/End, and Escape
  back to where it was opened from. An open menu counts as modal to
  type-to-search.
- A series card, wherever it appears: Open in a new tab and Copy link (the
  cards are links, so these stand in for the browser's menu), Favourite,
  Mark all read / unread through the Library's bulk routes, and for an admin
  Check for new chapters. Greyed offline; offered by role.
- The browser's menu is left alone on selected text, in a text field and
  with Shift held; Profile -> Settings -> Appearance -> Right-click menus
  turns them off per device.

Answers to the proposal's questions: right-click is taken only where the
browser's menu offers nothing worth keeping; the menus are short; Select mode
and the reader's pages are left for a follow-up (the reader's taps and zoom
already share that surface).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Downloads that never reached the library (#109), a direction for every
series so "Series default" can read right to left (#102, #105), right-click
menus (#100), an admin who is told when the library needs attention (#101),
a device that is no longer signed out after a lost refresh answer (#108,
capped at a day), the e2e flake fixed at its cause (#107), and undici 8.11.2
in place of the 8.11.0 that #96 held back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AngeloSha
AngeloSha merged commit f3036c1 into main Sep 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant