Skip to content

v2.5.5 - #982

Merged
ajslater merged 386 commits into
mainfrom
develop
Oct 19, 2026
Merged

v2.5.5#982
ajslater merged 386 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Features
    • csrf_trusted_origins setting for CSRF 403s behind a reverse proxy.
    • Docker images now use Python 3.15
  • Fixes
    • Online tagging matches saved for review by 2.5.3 no longer break the
      review list and scan status with "No module named 'numpy'". They are
      dropped, so those comics need tagging again.
    • Renamed comics no longer contain colons or other characters Windows and
      SMB shares can't store.
    • A Metron API key is no longer overridden by a ~/.netrc login.

ajslater and others added 30 commits August 26, 2026 19:33
Comics jumped Queued -> Matched in the admin tagging status table and
never showed "Looking up", though the right rail did. A regression from
7716fc5, which replaced the single Status column -- whose template
rendered the row-level in_flight with a spinner -- with per-source
columns. A per-source in_flight cell could never be published: the fold
set it on SearchStarted and cleared it on FileFinished, and comicbox
emits the whole per-file sequence before tag_many yields, which was the
only moment codex published. Every publish sampled the one instant when
no lookup was recorded.

Liveness is now a marker on the scan (SessionState.live) rather than a
residue in the event history: set from comicbox's SourceStarted (4.8.7,
which unlike SearchStarted also covers the explicit-id, stored-id and
series-cache fast paths) and from codex's own stored-id prepass, cleared
path-matched on FileFinished so a late event for a prior comic can't wipe
the current one. build_snapshot elects the in-flight row by identity and
projects one live cell onto it.

The row is no longer guessed from path_to_pk position. tag_many re-sorts
the batch by series fingerprint (series_batching defaults on), so that
position never meant processing order -- harmless while the row rendered
as "Queued" anyway, but not under a spinner claiming the comic is being
looked up right now. No marker means no in-flight row, which is honest
between comics and is exactly what Resume re-runs.

The live publish rides its own clock and its own dedupe. It must not
stamp _last_publish: that would throttle out the unforced result-boundary
publish carrying the terminal status and the resume descriptor. It needs
a time floor because comicbox answers a search from its on-disk HTTP
cache when it can, so a re-scan fires events at CPU speed with nothing
pacing them. And it is wholly guarded -- comicbox's _emit forwards
unguarded and SourceStarted fires before the lookup's own try, so an
escaping exception would land as a FileError and paint the comic ERROR in
every column. A status display must not be able to corrupt an outcome.

Snapshot changes now announce themselves on their own tag-session.snapshot
message instead of riding task.progress, which fires for every librarian
job and cost two other fetches per frame. The announcement lives in
_publish_snapshot so every write is followed by exactly one notification,
after the write rather than before it.

Also fixes two stale-state bugs this made reachable: deactivate_snapshot
could never repair a snapshot frozen inactive by a raised scan (its guard
required active), and the snapshot view now demotes a snapshot whose scan
is no longer the active one, closing the hard-kill window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_aggregate_settings_field groups every settings row without filtering on the
user FK the row already carries, so the sessions buckets merge registered users
with anonymous sessions. On any install with traffic that is overwhelmingly
sessions sitting on defaults nobody chose, which makes every setting read as
near-unanimously default and answers neither "is this default right" nor "does
anyone use this option".

The new per_user section counts people instead, two families per surface. The
live/global buckets are one vote per user and carry "" for a setting nobody
touched -- that untouched mass is the measure of whether a default is tolerated,
and it is not the same as choosing the value the setting defaults to. The chosen
buckets count every user who set a value anywhere, any scope, any saved view, so
they overlap and never carry "": a value nothing reaches is one nobody uses.

Three things the queries have to get right. The browser live row is filtered to
client=API and name="", because the unique constraint is (user, client, name)
and named saved settings and OPDS rows are additional rows for the same user --
counting them would let one person with three saved views outweigh two people.
Reach deliberately drops both filters, since a value saved under any name is
still someone using it. And a user with reader rows but no global row votes ""
rather than being left out, so the histogram partitions over the whole reader
population instead of quietly shrinking its own denominator.

The section is always emitted, with zero counts when there are no registered
users, never omitted. An absent section has to mean "this codex is too old to
send it"; chronicle already had to retrofit a migration because a hardcoded
zero was indistinguishable from a real one.

Named per_user rather than user_settings: the admin stats tab already had a
"User Settings" table rendering the unrelated sessions section. Both tables are
now titled for what they actually count -- Settings by Session and Settings by
User. That tab also had a live bug this makes acute: bucket labels resolve
through the choices maps, none of which has an empty-string key, so "" rendered
as the literal string "undefined" -- already visible under Fit To, and "" is the
headline value of the new family. Unset labels are added to the tab's own local
maps rather than the generated choices JSON, which make build-choices overwrites,
and an unmapped key now falls back to itself rather than to undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting only, no behaviour change: line wrapping in the table declaration
and two of the bucket-label helpers. Both this and the committed version
satisfy `prettier --check`, so this is cosmetic rather than a fix -- committed
to leave the tree clean rather than carrying a diff nobody meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ComicInfo AlternateSeries / AlternateNumber already imported into the
Reprint model, but there was no way to order by the alternate numbering
and no way to read an alternate series through in order. Alternate
series tags make durable reading lists — they survive a re-import,
rename or re-tag — which is what issue #360 asks for.

Add an "Alternate Number" sort. Reprint.issue is a string, so "#10"
sorted before "#2"; split it into issue_number / issue_suffix columns
mirroring Comic's, derived in presave and backfilled by migration. The
sort scopes to the alternate series picked by the reprints filter via a
FilteredRelation, the way story_arc_number scopes to the browsed arc.
Comics carrying no alternate number fall back to their own issue number
so a mixed listing stays readable rather than collapsing to NULL.

Surface the existing "Alternate Series" sort in cover view too, and
sort it through a Coalesce onto the real series sort_name so untagged
comics interleave instead of clumping under an empty aggregate.

Let the reader follow an alternate series as a reading order. An
alternate series is identified by (series_name, volume_number,
language) — the Reprint unique key minus the issue — and the arc handle
is the whole group's pks, since each issue is its own Reprint row and a
per-comic handle would change from book to book. Reprint rows join the
TimestampUpdater re-stamp flow and the mtime probe accepts them, so an
open reader still notices a re-import.

Also fixes a latent error: an M2M primary sort outside table view
(cover cards, OPDS) emitted an ORDER BY alias that was only annotated
in table mode. Unreachable through the UI before, but surfacing the
Alternate Series sort in cover view makes it a normal path.


Claude-Session: https://claude.ai/code/session_01CrB4hZ7HtGE9YjV5EkXeQ3

Co-authored-by: Claude <noreply@anthropic.com>
* fix(browser): send settings in the save request body

updateSettings passed the settings as a `params` wrapper, which xior sends
as the request body `{"params": {...}}`. The endpoint validates the body's
top level keys, so every one of them was unknown, the update validated as
empty and the request saved nothing.

Nothing was actually lost: the browse page request that follows a settings
change carries the same settings as query params and persists them. The
save request itself has just been doing nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* feat(browser): store a remembered sort per top collection

Sorting is one global setting today, so sorting issues by added time
leaves the publisher list in added-time order too. Issue #415 asks each
top collection to keep the sort it was last browsed with.

Add SettingsBrowser.collection_order_memory, a map of top collection to
the sort it was left in. It follows table_columns, the other per top
collection map: a JSON column in DIRECT_KEYS, so loading, saving,
resetting and saved-view cloning all carry it without further wiring.

The map round-trips out of stored settings, so the validator cleans
rather than rejects: unknown collections and sorts that no longer exist
are dropped with a warning instead of failing the whole browse page.
search_score is never stored, since relevance ordering only means
anything while its search is running.

Empty for every existing row, which reads as "nothing customized yet"
and keeps today's carry-the-sort-over behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* feat(browser): swap the remembered sort when the collection changes

Switching top collections now files the sort the old one is left in and
hands back the sort the new one was last browsed with. A collection with
nothing filed keeps whatever sort arrives with it, so nothing changes
until you sort something.

Searching does the same pair: entering a search files the sort it
displaces and clearing the search puts it back, instead of always
landing on sort_name.

Whether a payload is the user changing collections is decided before the
validators run, since they inject an orderBy of their own that would
otherwise look like a payload that already carries a sort. Payloads that
really do carry one -- settings loads, saved views, redirects -- keep it.

The memory rides the settings the browse page request already echoes, so
there is no new request to persist it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* feat(browser): carry the sort when the server moves the top collection

The browser store handles collection switches the user makes, but the
server makes some of its own: a url whose collection forces a different
top, or a redirect after folder view is turned off. Those bypass the
store, so the old collection's sort leaked into the new one and would
later be filed under the wrong collection, overwriting a real memory.

Apply the same file-and-restore in the settings GET when validation
rewrites the top collection, and in raise_redirect, which is the one
place every settings-mask redirect passes through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* feat(user_data): back up and restore the collection sort memory

The sidecar adds columns it is missing when it opens, so a backup written
before this column restores an empty memory rather than failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* test(browser): cover the collection sort memory

Backend: the field's wiring, the lenient validator, the file-and-restore
helper, and the HTTP round trips -- PATCH, the browse page query params
that actually persist the map, reset, saved views, and a url that forces
a different top collection.

Frontend: switching collections, coming back to one, a collection with
nothing filed, payloads that carry their own sort, and the sort a search
displaces and gives back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

* chore: drop an unused order-defaults helper, add news

_get_browser_order_defaults has no callers; set_order_by_default is the
copy that runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwhwRtnMZ2pLcnhF96AyWA

---------

Co-authored-by: Claude <noreply@anthropic.com>
0053 and 0054 both ship in v2.3.0, so there is no reason for a migrated
database to walk through them separately. 0054's collection_order_memory
field moves into 0053 next to the other settingsbrowser operation, and
0053 is renamed to cover both changes.

Fold the v2.4.0 news entries into v2.3.0 for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The collection order memory test helper had no annotation, so its params
dict inferred a str | bool | list | dict value type and every index into
it was an error. Annotate it dict[str, Any], which is what the helper
already is where it feeds apply_collection_order_memory.

Drop a pyright ignore the isinstance guard above it made unnecessary, and
build the two dropped-entry warnings as parenthesized msg variables like
validate_table_columns above already does. Pyright only objects to an
implicit string concatenation that isn't parenthesized on its own, which
is why the older sibling never tripped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Merge the Alternate Number sort into the Alternate Series sort

One sort key now does both halves: comics group by their alternate
series (ComicInfo AlternateSeries / MetronInfo reprints) and order by
the parsed AlternateNumber inside the group, falling back to the
comic's own series and issue so untagged comics interleave.

A correlated subquery elects one reprint per comic by natural tuple
order and ORDER BY is a five-field Coalesce list reading from that
election — numbers compare numerically, and the shared election
ordering keeps the tuple atomic (the old sort aggregated number and
suffix independently and could pair parts from different reprints).
An active reprints filter narrows the election instead of using a
FilteredRelation. Benchmarked at parity with a join+aggregate at 120k
comics; the fallback series segment uses lower(name) so article-led
titles interleave with their alternate namesakes.

Collection rows sort by a matching fixed-width key in the intersection
RawSQL, with an in-SQL fallback to the collection's own name. The key
reads only reprint columns (a per-comic fallback under GROUP BY came
back from an arbitrary row) and its printf formats are %%-escaped so
the DEBUG query logger doesn't consume bound params.

alternate_number is removed from the order-by choices (0053 edited in
place, unreleased). New 0054 remaps stored settings in order_by,
order_extra_keys and collection_order_memory with dedupe; sidecar
restore gets a sort-key rename map covering the same three homes.
Cover-pick subqueries for the merged key normalize to sort_name like
every other M2M sort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ignore tasks

* ignore tasks

* prettier

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The 0054 data migration remapped stored alternate_number sort keys, but
it can only fire on a database that already applied 0053 (so 0053 never
re-runs) AND holds the key — the dev database, which is being rebuilt.
On a fresh database it finds zero rows, and the key never reached a
release, so it is dead by construction. 0053 is the only migration
again. The sidecar restore rename stays: a sidecar written by a
development build outlives the database it came from, and that is now
the only way the retired key can enter a fresh install.

The concept is renamed to Reprints across the sort, table column,
filter, metadata panel, tag editor and the reader's reading-order menu.
"Alternate Series" is ComicInfo's word for one of five sources —
MetronInfo contributes both Reprints and localized AlternativeNames,
CoMet isVersionOf, and the Metron and ComicVine APIs their own — and it
misdescribes the localized names. MetronInfo, comicbox and the ORM all
already say reprints, so the UI now matches the data model end to end
and the internal vocabulary stops needing translation.

0053's choices tuple is edited in place (unreleased); released 0049
keeps its historical label, since the final migrated state comes from
0053. The redundant metadata TAG_NAMES override is dropped now that the
label is the capital-cased key.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
d88f4f1 split the "User Settings" table into "Settings by Session" and
"Settings by User", but the spec kept its own copy of the section titles and
still asserted the old name, so it failed.

Rather than retype the new names into the test, the tab now declares its
tables once as a SECTIONS list the template loops over, and exports the
titles. The spec compares the rendered captions to that export, so a rename
cannot leave a stale copy behind again.

The assertion is the whole ordered caption list rather than a containment
check per title. With the titles derived, containment would pass vacuously;
comparing the list still catches a section that stops rendering, one rendered
twice, or an empty title.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Unclip the settings button's librarian progress ring

Vuetify 4.2.0 added `overflow: hidden` to `.v-btn` (fix(variant) #22992),
which makes the button both the containing block and the clip box for the
admin overlays inside it. The 32px progress ring does not fit the 28px
compact icon button, so on xs it lost its left and bottom arcs; the
safe-area padding clips it on notched phones in landscape too.

Restore the pre-4.2 behavior for this one button. The scoped id selector is
unlayered so it wins without `!important`, and nothing escapes that the
Vuetify clip was meant to contain: `variant="plain"` hides the button
overlay, the ripple container clips itself, and inside `v-toolbar-items` the
button has no border radius.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the filter menu's extra rows keyboard reachable

Vuetify 4.2.0 moved select keyboard navigation into `useScrolling`, whose
list keydown capture wraps from either end of the item rows and calls
`stopImmediatePropagation()`. That pre-empts VList's own focus walk, which
was what used to carry the user from the bookmark rows into the rows this
menu contributes through the prepend/append slots, so "Clear All Filters",
"Favorites Only" and the filter sub-menus became mouse-only.

A capture listener on the overlay content runs before the list's, so
stepping off either end of the bookmark rows now lands on the adjacent slot
row instead of wrapping. Every other key and row is left alone and Vuetify
still owns the navigation. The neighbour is found by walking the content's
focusable rows rather than by naming the slot rows, so it follows the rows
that are actually rendered: logged out, there is no "Favorites Only" and the
step lands on the first filter sub-menu.

Also cover the other 4.2 delta in this menu: `closeOnSelect()` now bails
when `menuProps` carries `closeOnContentClick: false`, which leaves
`onSubMenuSelected` as the only thing closing the menu after a pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the book change drawer's conditional width

The drawer is 33vw rather than Vuetify's 256px default. Vuetify 4.1 parked
an inactive layout item at `translateX(-(width prop + 1)px)`, computed from
the prop and not the rendered box, so a 33vw drawer stayed partly on screen
when closed. 24f8d1b worked around that by applying the width only while
the drawer was open, which also made closing shrink it from 33vw to 256px
mid-slide.

Vuetify 4.2.0 parks it at `calc(+/-100% +/- 1px)` of its own rendered box,
so the width can be unconditional and the close is a plain slide. Tests pin
the offscreen transform that makes this safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The vertical pager hands `v-virtual-scroll` a list of page numbers and
reverses that list for bottom-to-top reading, so page 0 becomes the last
item. `scrollToIndex` takes an index into that list, but `scrollToPage`
passed the page number straight through, so every jump landed on the
mirrored page: opening a comic at page 0 scrolled to the last page.

Look the page up in `items` rather than repeating the reversal, so this
keeps following however that list is built. Top to Bottom is unaffected,
where a page number is already its own index; a test pins that too.

Page tracking was never wrong. The intersection observer reads the real page
number off the element, so only jumping to a page was affected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(comicbox): adopt comicbox 5.0.0's moved and renamed APIs

Installing comicbox 5.0.0 broke 27 test modules at import: MatchMode and
its neighbours moved to comicbox.config.online, WriteMode became
MergeMode, and ID_URL_KEY and NUMBER_TO_KEY are gone. Those cascade
through the URL conf, so almost nothing ran.

Identifiers no longer carry a url. Comicbox derives links from the key
rather than storing a copy that could disagree with it, so codex derives
its Identifier.url column the same way, through comicbox's own helpers.
The bridge between the two type vocabularies -- codex names a type after
the table it points at, comicbox after the thing itself -- is the
IdentifierType enum's own member names, so neither side can drift.

The online cache directory travelled as COMICBOX_ONLINE_CACHE_DIR, a name
comicbox 5.0 no longer reads. It now rides a settings object handed to
each session, which is not a name comicbox is free to change. The online
settings deliberately carry no delete_keys: that set is the read side's
parse-skip list, and online tagging writes what a database returns
straight back to the archive.

The fixtures become v3.0 documents. Two of their entries were malformed
in a way 4.8.7 dropped silently and 5.0 now names in a warning: a null
team voided the whole teams field, and a credit role's identifier was
keyed by "key" instead of by its source. Both are repaired, which
restores the coverage they were quietly costing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(identifiers): file an identifier under the type it states

A comicbox 5 identifier names its type whenever that type isn't the one
its position implies, so a comic can carry an id for its series or its
volume among its own. Codex filed every comic-level identifier as an
issue id and built the wrong url for the ones that weren't.

Codex and comicbox name these types differently -- codex after the table
the id points at, comicbox after the thing itself -- and the bridge is
derived from the IdentifierType member names rather than written out a
second time. A drift guard holds the two vocabularies together, and it
found the first gap immediately: comicbox has a volume type codex had no
member for. Migration 0054 adds it.

The tag editor now speaks comicbox's names throughout. It used to offer
codex's, glue the type onto the front of the key, and send an always
empty url, while the add-by-URL endpoint beside it returned comicbox's
names -- so a URL-added identifier landed on a type its own select could
not display.

A reprint keeps the name the file gave it, which is what stands in when
comicbox parsed no series out of that name. MetronInfo's alternative
names, the reason the old series sort_name fallback existed, no longer
arrive as reprints at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(metadata): store the facts comicbox 5 separates out

Comicbox 5 reports several things codex had no column for, and the
importer's field whitelist is derived from comicbox's own schema, so
every one of them was being skipped in silence.

manga and reading direction are now two facts rather than ComicInfo's
one compound YesAndRightToLeft value, and each gets a column and an
editor field. MetronInfo's MangaVolume gets one too.

A comic's web links used to ride along inside its identifiers. An
identifier is now only a key -- codex derives its link from that key
rather than storing a copy that could disagree -- so the links the file
itself wrote down needed somewhere else to live.

The primary credit flag moved onto each role, which is the point of it:
the primary writer is not thereby also the primary inker. Codex stores
it on the person-and-role pairing, and since credit rows are shared
between comics, the flag has to be part of what identifies one.

The series' other names are no longer reprints upstream, but they still
mean what a reprint means -- another edition of this book -- so they
keep sharing the table, flagged. The flag is what the editor consults to
put each row back in the list it came from, and it splits the metadata
panel's one row into the two things a reader is actually looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(onlinetag): spend API budget by effort, and price the run by it

Comicbox 5 bounds Comic Vine's per-candidate fan-out by default and
takes an effort setting to widen or narrow it, which codex had no way to
reach: effort is not an OnlineSession keyword, it lives in the settings
tree the session layers over. Codex now hands those settings across, so
the same channel carries the cache directory and the scan's effort.

The run estimate moves onto that axis too. It was keyed on match mode,
which changes no request count at all -- match mode decides how a verdict
is applied once the calls are already spent. The default projection
roughly triples as a result. That is the estimate becoming honest about
a cold search, not the run getting slower: a real scan batches by series,
so one search answers for a whole series and beats the projection.

Two identifier workarounds come out, now that comicbox files both cases
correctly. A series' other names state that their ids name a series, so
codex stops injecting that. A reprint's id is the reprinted issue's id,
so it resolves to an issue page instead of to nothing.

The sources strip learns that Comic Vine meters per endpoint pool rather
than per account. It shows the pool with the least left, since that is
the one that will stop the run, and says which window the number covers.

A prompt is replayed by handing its fingerprint back to comicbox, and
comicbox 5 changed what goes into one, so prompts stored by comicbox 4
can never match again. They are dropped at daemon start and nightly
rather than by a migration: the tagging cache lives under the config
directory, where a test run's migrations would reach real prompts.

Comic Vine credential validation starts working. Simyan 3 returned a
rejected key's error body verbatim, so the empty result read as success;
simyan 4 raises, which the existing handler already caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(migrations): ask for one full re-read under comicbox 5

Comicbox 5 changes what existing files mean without changing the files:
age ratings collapse to MetronInfo's seven names, credit roles
canonicalize, ComicInfo's AlternateSeries reads as a story arc, a manga
tag now also sets a reading direction, and a stated title stops being
rebuilt from the story list.

Codex re-reads a comic only when its file changed, so left alone a
library would hold both readings at once -- the old one everywhere, the
new one on whatever happened to be re-tagged -- and its browser facets
would list Everyone beside Everyone 10+ as though they were different
things.

Clearing the stored stat is how the re-read is asked for: the poller
reads a missing stat as "stat the file and call it modified", and the
importer's prefilter skips only on a stat that matches or an
unadvanced metadata mtime. It is resumable by construction, since a
comic gets its stat back only once it has been imported.

The import marker is deliberately left alone. It drives the "not
imported" hint, and clearing it would blank that hint library-wide for
the length of the re-read, which reads as data loss rather than as work
in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(news): record the comicbox 5 work under the unreleased 2.3.0

Folded into 2.3.0 rather than opening a 2.4.0: 2.3.0 has never been
tagged, so there is no release anyone has seen for this to come after.

The Dev section carries the two things an upgrading admin has to know
before they see them happen -- every comic is read again on the first
scan, and saved tagging prompts are discarded -- along with what the
re-read will visibly change about their tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* add script

* refactor(migrations): one migration, and no forced re-read

Squashes the three comicbox 5 migrations into 0054, and drops the one
that asked for a full re-read.

Re-reading a library is an admin's call, not an upgrade's. The new
columns can only be filled from the files, so filling them means opening
every archive, and doing that unprompted on the first scan after an
upgrade spends hours of someone's disk on a decision they were never
asked about. Force Update Tags already does it, on whatever scope the
admin picks, and the release notes now say so.

The cost of not re-reading is a library that reads its tags two ways at
once: age ratings and credit roles keep comicbox 4's spellings until a
comic is next read, so a filter can offer both Everyone 10+ and
Everyone. That is untidy rather than harmful -- age-restricted access
derives from the Metron scale, which both spellings still map onto
identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(importer): key a credit that names no role

A file may credit someone without saying what they did, and comicbox
passes that through as a person with an empty role list. Codex built a
key with only the person in it, and a short key is padded with nulls on
its way to the database. That was survivable while the padded column was
the role, which is nullable. The primary flag is not, so the first
role-less credit in a library aborted the whole import with an
IntegrityError -- taking every other comic in the batch with it.

Such a credit now keys as (person, no role, not primary), which is what
it means: there is no role for the person to be the primary holder of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update deps

* refactor(migrations): fold the comicbox 5 columns into 0053

0053 is unreleased too -- v2.2.11 ships 0052 -- so everything here lands
in v2.3.0 together and a database has no reason to walk through it in
two steps. Renamed to cover what it now does.

This is the same fold as "Combine the two unreleased migrations into
0053", one release later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemeter): count the comicbox 5 tags, and show them honestly

Comicbox 5 gave codex several new facts about a library and nothing counted
any of them. The stats report now says how many comics are manga, carry a
manga volume or carry web links, how many credits mark a primary holder, and
how many reprints are a series' other names. It also reports the Effort
default, which decides how much API budget a scan spends per comic.

Counts only, and every key is a name fixed in this source. manga is three
scalar counts rather than a bucket for exactly that reason: the payload's
shape then cannot be invented by a value read out of a comic file, and a new
comicbox manga value fails the serializer-registration test instead of quietly
becoming a new key. Anything outside the vocabulary is folded into unknown, so
the three still partition the library. Never the volume string, never the
links, never a person or a role.

Two of the columns needed their own counter. The existing one asks whether a
column is NULL, and manga_volume and urls are NOT NULL with empty defaults, so
it would have reported the whole library for both. There is a regression test
for that, and the privacy suite now seeds a comic carrying both columns --
without one, the sentinel walk could not tell a count of them from a report of
them.

None of these can be backfilled, since they can only be filled by reading a
file, so they all read zero until an install re-reads. That is in the release
notes rather than left to be discovered.

Then the Admin Stats tab, which is the visible contract for what leaves an
install, and which had been misreporting it. The whole Settings by User table
was empty: it read stats.per_user while the API renders the payload camelCased
and ships perUser, so the only telemeter addition since 2.2.11 was invisible
on the one page that exists to disclose it. Order By read "sortName" and Fit
To read "W", because the labels were looked up with a case conversion applied
to keys the choices files already emit camelCased. And four rows that detail
the row above them rendered flat, because the indent set held plural spellings
that matched no payload key. Boolean buckets read the wire's "true" and
"false"; they now say On and Off, the same words chronicle's dashboard uses
for those numbers.

The tab's test fixture is now camelCased like the wire it stands in for, which
is what let all three of those pass, and a new assertion requires every
populated section to render at least one row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* trim news

* refactor(onlinetag): follow comicbox 5's OnlineSession rename

comicbox 5 renames the session facade's mode= to match= and
unattended= to prompts=, so the public API says what the CLI and the
config keys have always said (ajslater/comicbox#205).

Codex only ever passed mode=, so that is all that moves here, at both
construction sites, plus the DeferredPrompt attribute the prompt
serializer reads. The serialized prompt's own "mode" key stays: it is
codex's cache format, versioned by PROMPT_VERSION, and renaming it
would strand every prompt already persisted.

Nothing else in the rename reaches codex. It never used unattended=,
never called set_mode/set_unattended, and the only PromptResponse it
builds is a plain "skip" — so the set_unattended -> set_prompts action
rename, the one break that fails quietly rather than loudly, has no
call site here.

FakeDP carries .match now. The fake standing in for a comicbox object
has to track the real attribute name or it hides exactly this kind of
break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update deps

* fix(covers): serve conditional GETs instead of caching response bodies

The per-pk cover routes carried `cache_page`, which stored a copy of every
cover response in the default cache. Because the page-cache key hashes the
Vary header values, and these routes vary on Cookie (plus Authorization for
OPDS), that meant one stored copy per user of bytes that already exist once
on disk as a webp. Session-cookie rotation orphaned entries, a 100-cover page
could populate 200+ of them, and a single pk could never be invalidated --
only a clear() of the whole cache, which import finish, tag writes, and
Library/Group CRUD all do. Covers were collateral on every scan.

Serve ETag and Last-Modified derived from the thumb's size and mtime instead.
A revalidating client gets a 304 answered from the stat alone, without the
image bytes being read, and a regenerated cover invalidates itself.

Also:

- /api/v4/covers/<source>/<pk>, the route the web client uses for every cover
  on a browse page, had neither Cache-Control nor Vary. It now has both; the
  ?ts=<mtime> cache-buster already made it safe to cache.
- Comic covers are private rather than public. They are ACL-gated, so a shared
  proxy must not store them. Custom covers have no ACL and stay public.
- Cover and reader-page views are exempt from throttling. `cache_page` used to
  short-circuit before DRF dispatched, so cover hits never reached the
  throttles; without it a browse page would spend 100 requests against a
  configured throttle.user rate for one user action.
- BROWSER_TIMEOUT and READER_TIMEOUT are removed. No browser or reader route
  has ever been cache_page'd, so the MAX_ENTRIES comment was partly justifying
  a cache population that does not exist.

Adds tests/test_cover_views.py, which had no predecessor -- the cover
endpoints were previously untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onlinetag): let comicbox spare a large scan, and add a way back up

Two things, found checking codex against the comicbox 5.0.0 release.

Effort now defaults to Auto rather than Balanced. Comicbox reads any
named effort as a decision to leave alone, and only while none is named
is it free to drop a large unattended scan to minimal -- the protection
it added precisely because balanced fans out enough Comic Vine calls to
blow past the hourly cap and stretch a big library into hours. Codex
named balanced on every scan, so nobody ever got that. Naming one is
still available and still honored; it just isn't the default.

The release also renamed the session's mode= to match= and unattended=
to prompts=. Production code already followed that; one test fake had
not.

Separately: a group whose parents have gone missing yields a single
breadcrumb, which is the current view and so no link at all, leaving a
deep route with nothing to climb and no way out but the back button. It
now offers the top, which is the one destination always there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(reader): close a book to a route that exists

The close button's last-resort fallback read a key the browser defaults
do not have, so the one branch meant to guarantee the reader stays
closable would have thrown a TypeError and taken the render down with
it. The defaults call that route lastRoute, which is what the router
itself already falls back to.

Unreachable today: the server always injects a last route, its own
fallback being the admin default. It was a landmine rather than a live
crash, and nothing exercised it, so a test now covers both the ordinary
close and the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop leaking a DB connection per ASGI request, and pool the workers that hold them (#838)

* fix(db): stop leaking a database connection per ASGI request

Every DB-touching request orphaned an open SQLite connection until the
garbage collector emitted "ResourceWarning: unclosed database". A page
of covers leaked dozens, three file descriptors apiece under WAL, which
is what codex/run.py's RLIMIT_NOFILE bump was papering over.

Django's ASGIHandler wraps each request in an asgiref
ThreadSensitiveContext, which is backed by a private one-thread executor
that is shut down when the request ends. django.db.connections is
thread-local, so CONN_MAX_AGE=600 kept a connection alive on a thread
about to be retired: never reusable, never closed. Django documents this
("When using ASGI, persistent connections should be disabled") and
closed #33497 as documentation only, so this is by design, not a bug.

Take the documented configuration: DATABASES leaves CONN_MAX_AGE at
Django's default of 0, and the processes whose threads actually persist
opt back in for themselves. The librarian does so as the first statement
of its run loop, before it spawns the workers that reuse a connection
across tasks.

That covers every path Django controls, but not one it doesn't:
send_response runs inside handle()'s TaskGroup, and if it raises, the
exception group is re-raised before either request_finished or
response.close(). A streaming body is consumed there, outside the
middleware chain, so an iterator that raises is never converted to a
500 -- and codex streams comic archives off a user's library, where the
librarian can move a file mid-download. ConnectionClosingApplication
stays as the backstop for that exit, now documented as such.

Two more connections nothing was closing: the admin restore endpoint's
sidecar sqlite handle, and the admin stats view's off-loop query, which
runs on an event-loop executor thread no request lifecycle cleans up.

Also corrects six comments estimating the reconnect cost at "~5-20 ms".
It measures ~1 ms, of which the PRAGMAs everyone worried about are
~0.01 ms; ~90% is SQLite re-parsing the schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(db): serve requests on pooled workers that keep a warm connection

Django gives every ASGI request a private worker thread and retires it
at the end of the request, so each DB-touching request opened a fresh
SQLite connection. Measured on this codebase that is ~0.76 ms, of which
~90% is SQLite re-parsing the schema (84 tables + 266 indexes); with
per-connection statement re-preparation a five-query request pays
~1.26 ms, 53% of its database time. It also means cache_size=64MB and
the 256MB mmap window can never warm on the HTTP path, and that a cold
cover grid opens ~100 connections at three file descriptors each.

Django's answer to this is pooling, but its pool is PostgreSQL-only and
a SQLite equivalent is unsafe: a handle returned with an unexhausted
cursor pins a WAL read snapshot that rollback() does not release, so the
next borrower reads stale rows and its first write fails "database is
locked". Pool the threads instead.

WorkerPool owns a fixed set of ThreadSensitiveContext objects that are
never entered, pre-seeds each with its own one-thread executor, and
lends one to each request. asgiref's context manager is re-entrant, so
Django's inner "async with" is a no-op and the worker -- with its
thread-local connection -- outlives the request. That restores the
stable-thread model CONN_MAX_AGE was written for, so the pool opts this
process into persistent connections and Django's own request_started /
request_finished hygiene recycles them exactly as under WSGI. Nine
requests that opened six connections now open one.

The pool reaches into two of asgiref's class attributes, which have been
stable since 3.3.2 but are not a documented contract. So it checks them
at startup rather than trusting them: if thread-sensitive work no longer
lands on the seeded worker, the pool logs and disables itself, and every
request takes the per-request path from the previous commit. asgiref is
now a direct dependency so a major bump is a conscious upgrade, and a
test pins the behavior so it fails loudly rather than silently
degrading.

Requests that would hold a worker for a client-speed transfer bypass the
pool: the file downloads, and the health check, whose whole job is to
answer even when the pool is saturated.

Size it with db_workers under [server]; 0 restores per-request
connections. Each worker can grow its own page cache, so lower it on a
memory-constrained host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
typecheck (basedpyright), 3 warnings -> 0:
- workers.py, librariand.py: build log messages before the call, since
  ruff's ISC003 rejects the explicit `+` form.
- test_asgi_db_connections.py: mark SlowOrmApplication._query @OverRide.

ty, 6 warnings -> 0. All were dead conditions; one hid a real bug:
- search/sync.py: _update_search_index_clean was typed `-> None` and
  discarded the count from remove_stale_records, so `elif cleaned_count:`
  never fired and the search index summary never reported cleaned
  entries. Return the count (0 on the rebuild path, reported separately).
- crond.py: `not self._task_times[0]` on a 2-tuple.
- search/parse.py: `if match else` on a finditer result.
- opds metadata/publications/manifest: `not OPDS_M2M_MODELS` guards
  against a 7-element module constant.

complexity (radon cc), 3 C-rank functions -> A/B:
- _build_browser_defaults C(11) -> B(6): extract _rename_table_columns,
  fold the repeated `row[x] or ""` / `bool(row[x])` lines into column
  tuples, and reuse _rename_sort_key for order_by.
- test_new_sections_have_content -> A(5): assert list becomes a table.
- test_facet_ua_gets_facet_links_no_fake_entries -> A(3): body extracted
  to _assert_facet_feed, three-way title check becomes one regex.

Both restructured tests were mutation-checked to confirm they still fail
when their invariant breaks. make fix/lint clean, 1094 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tatus, live status rail (#840)

* Default Auto Update on outside Docker

Installing a new codex over the running one is what a pip, pipx or uv
install wants, so Auto Update now seeds on there. A container is an
immutable deployment — an in-place upgrade only lives until the next
restart brings the image's version back — so docker keeps seeding it
off and updates by image pull.

Seeding is still get_or_create, so an existing install (and an admin
who turned the flag off) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Report a source that ran and found nothing as No match

Comicbox emits a terminal event only for the resolutions its matcher
reaches. A search that comes back with zero candidates — routine for
Metron, whose database is much smaller than Comic Vine's — ends that
source's turn with a log line and no event at all, as does a search that
raises and a comic with nothing to search on. The status table had no
cell to render and fell back to an em-dash, which reads as "this source
was never consulted": the one thing that had not happened.

FileFinished now closes the comic. Every source that announced itself
with SourceStarted (emitted after comicbox's first-wins skip, so a source
that sat out never appears) and reported nothing settles as no_match, and
an empty cell again means only what it looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Move the status rail in step with the online lookup

The admin drawer's rail renders the LibrarianStatus row, which an online
tagging scan only touched when a comic *completed* — a minute of network
per comic during which the rail sat still while the Tagging tab's table,
fed by its own snapshot notification, plainly showed work happening.

The live-lookup marker now names the source it is consulting in the
status subtitle, so the rail gets the same heartbeat, paced by the live
publish's existing one-second floor.

Two supporting fixes in StatusController:

- update() takes force, for a state change rather than a progress tick.
  Dropping one of those into the five-second coalescing window loses it
  instead of deferring it, since it *is* the whole update. The rate-limit
  path was already doing this by reaching in and backdating the status's
  own clock; it asks now.
- The subtitle is written on every update, including an empty one. It was
  skipped as "nothing to write", so a phase's subtitle outlived it: a
  recovered rate limit kept describing the task as rate limited, and the
  importer's own subtitle clear never took.

Also drops a verbatim duplicate of tagWriteErrorsNotified in the socket
store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Both of the previous PR's fixes hooked the event-emitting search session.
Neither one fires for a re-tag, because comics codex already holds an
issue id for never reach that session: the stored-id prepass fetches them
by explicit id outside it, and on a re-tag that prepass is the whole run.

Two consequences, both visible in the admin Tagging tab:

- A merged multi-source refresh was credited to the primary source alone.
  Under merge-all the prepass pins every stored id and comicbox fetches
  and merges them all, so a comic refreshed from Metron *and* Comic Vine
  showed Matched in one column and an em-dash in the other — the cell that
  reads as "this source was never consulted". Each source whose requested
  id comes back in the merged record is now credited; the primary is
  proven by the fetch itself, which returns nothing unless its own id
  landed. A source the scan never pinned (first-wins, or no credentials)
  still reports nothing, which is what the em-dash is for.

- Only the search pass opened a librarian status row, so a scan that
  resolved every comic from a stored id ran start to finish with an empty
  status rail, while the Tagging tab's table — published from the same
  loop — showed each lookup as it happened. The prepass now opens the row,
  totalled over the whole batch, advances it per comic, and the search
  pass adopts it instead of starting a second one, so the rail shows one
  continuous job whose elapsed time counts the prepass. The live marker's
  "looking up on <source>" subtitle lands there too, which is what it was
  missing to reach the rail at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
github-actions Bot and others added 29 commits October 8, 2026 03:38
* Read stale pickled tagging-cache entries as misses, not 500s

Codex 2.5.3 (comicbox 5.2.2) pickled imagehash's numpy.float64 cover
scores into pending online-tag prompts. 2.5.4 (comicbox 5.3.1) ships
without numpy, so every read of those prompts raised
ModuleNotFoundError: the daemon's startup prune (logged under "Clearing
stale online tag scan marker on startup"), /admin/tag-prompts and
/admin/tag-sessions/snapshot.

- ResilientFileBasedCache also treats ImportError and AttributeError on
  load as an unreadable entry: delete the file, report a miss. A pickle
  naming a module or class that no longer exists is as unloadable as a
  truncated one. The discard now logs a warning, so an admin whose
  review queue emptied after an upgrade can see why.
- serialize_candidate casts score, metadata_score and cover_score to
  plain float, so a library's number type can't end up in the cache
  again.

Prompts already stored with numpy scores are dropped; those comics need
another online-tag pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Log discarded cache entries with their key and traceback

The discard deletes the file, so the log line is the only record of
what was dropped and why. Name the cache key (the file name is a hash)
and attach the traceback, so an error class caught there by mistake
can be traced from the log alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* NEWS for v2.5.5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* style: satisfy the unicorn and ruff rules the toolchain bump added

Pre-existing on develop, where pushes do not run CI: eslint-plugin-unicorn
prefer-ternary and no-immediate-mutation in three frontend files, and ruff
ICN001 (`import datetime as dt`) in four Python files. Behavior unchanged;
`make lint` could not reach the shell, Dockerfile and workflow checks until
these passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: install the lint tools in the codex-ci stage; update devenv to 0.2.9

devenv 0.2.9 makes a missing lint tool an error in CI instead of a
skip, so the codex-ci stage now copies shellcheck, shfmt, hadolint,
actionlint and dockerfmt from their upstream images and unpacks
shellharden's latest release, as devenv's README prescribes. apt's
shellcheck goes, so the image runs the same version as the dev
machines.

The devenv update brings the `need` guard in bin/_lib.sh, forwards CI
into the compose service, and adds the devenv-lint-tools action and
the native-mode install step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: keep the two directory symlinks out of the sdist

uv's sdist builder reads docs/NEWS/codex and docs/img, symlinks to
directories, as files and fails with "Is a directory". Pre-existing on
develop, where pushes do not run CI; it broke the Build Dist job. The
site builds from the checkout, so the sdist does not need them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* update deps

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`make ty` (ty 0.0.89) failed with 99 diagnostics on develop.

False positives (84): every `class Meta(<Base>.Meta)` in codex/models and
codex/serializers trips invalid-attribute-override
(astral-sh/ty#4711). A [[tool.ty.overrides]]
block turns that rule off for those two trees only, as comicbox 5.3.2
does for its schemas. It also hides the deliberate Volume.name and
CustomCover.library overrides there; the pyproject comment says to mark
them with ty ignores when the block goes.

Real (15):
- permission_classes (7): AuthMixin declared
  Sequence[type[BasePermission]], narrower than DRF's
  Sequence[_PermissionClass], so every subclass that set its own classes
  clashed with APIView. Using DRF's type fixes all seven, lets three
  views drop their copied annotation, and retires three pyright
  reportIncompatibleVariableOverride ignores (AuthAPIView,
  AuthGenericAPIView, OpenSearch1View).
- input_serializer_class (4): BrowserParamsView typed it
  type[BrowserSettingsSerializerBase], yet choices, metadata and mtime
  set a superclass or sibling serializer. Widened to their common base,
  BrowserFilterChoicesInputSerializer, which is all init_params needs;
  subclasses now inherit the type, dropping three pyright ignores.
- NULL_CODE (1): the mixin is typed int | str, since VuetifyCharField
  compares against a str; drops a pyright ignore.
- content_negotiation_class (1): djangorestframework-stubs types it
  str | None, but DRF resolves the setting to a class, so the override
  is right; ignored for both checkers with that reason.
- Volume.name, CustomCover.library (2): deliberate type-changing model
  overrides, covered by the scoped override above.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
comicbox 5.3.2 (pinned in c36f194) needs no code changes here, only
accurate comments and the user-facing fixes it brings in NEWS:

- Renames strip ":" and the other characters Windows and SMB shares
  can't store. Codex has no filename sanitizing of its own to retire.
- MetronOnlineSource now sends mokkari the API token alone when one is
  set, so a leftover username & password never reaches mokkari, and
  mokkari 4.10 stops ~/.netrc replacing the Bearer token. Codex still
  hands comicbox all three fields, so comments that said "mokkari
  prefers the token" now say comicbox sends only the token.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A user behind a reverse proxy got CSRF 403s and worked around them by
removing CsrfViewMiddleware. That is unsafe and doesn't even work for the
API: DRF's SessionAuthentication enforces CSRF itself. Codex never set
Django's CSRF_TRUSTED_ORIGINS, so a proxy that serves Codex under another
scheme or host (usually one missing X-Forwarded-Proto) had no fix.

[server] csrf_trusted_origins (TOML array) or CODEX_CSRF_TRUSTED_ORIGINS
(comma-separated) now feeds CSRF_TRUSTED_ORIGINS. Entries lose a trailing
slash and are deduped; a scheme-less entry, which could never match an
Origin header, is dropped with a warning that points at the
X-Forwarded-* headers first. The default is empty, so existing installs
are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ins (#979)

The setting is a fallback for a proxy that can't send X-Forwarded-Proto
and X-Forwarded-Host. Say so next to that advice in the codex.toml
comments and the README's "CSRF 403 behind a proxy" section, so users fix
the proxy first instead of reaching for the setting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#910 pinned dynamic covers, custom covers and finish-on-last-page on and
read-RTL-in-reverse off, and removed their checkboxes, but kept the columns,
serializer fields, defaults, telemetry buckets and sidecar export so the
toggles could come back with a revert. Nobody needs them back, so they go:

- Migration 0057 drops SettingsBrowser.custom_covers/dynamic_covers and
  SettingsReader.finish_on_last_page/read_rtl_in_reverse. They shipped in
  0038, so this is a real RemoveField migration, not a fold edit.
- BROWSER_DEFAULTS/READER_DEFAULTS, the browser, reader and session
  serializers, the telemetry and admin stats buckets, and the user-data
  sidecar schema/dump/restore lose the keys. The sidecar SCHEMA_VERSION is
  unchanged: the store only adds columns and restore reads named columns,
  so a pre-removal sidecar still restores (the legacy restore fixture keeps
  the two keys as the regression guard).
- Dead code the pin left behind goes too: the sort-name fuzzy cover match
  and parent_route, the custom-cover early return, the unused
  BrowserCoverInputSerializer, the coverSettings getter and its key lists,
  setReadRTLInReverse, and the parked browser-settings-covers component.
  BrowserCoverInputSerializerBase folds into BrowserSettingsSerializerBase,
  and get_reader_defaults now iterates READER_DEFAULTS.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Give each pytest process its own config dir

Every test run shared ./config: the developer's real codex.toml, secret
keys and, above all, the file-based Django caches that cachalot fills.
A -p plugin now points CODEX_CONFIG_DIR at a per-process directory
under the run's tmp root before pytest-django imports the settings, so
serial runs, parallel worktrees and (next) xdist workers never share a
cache. An explicit CODEX_CONFIG_DIR still wins in a serial run.

With that isolation the SerializeMixin file lock on BaseTestImporter
only serialized importer classes across processes, so it goes.

--durations=25 --durations-min=1.0 makes slow tests visible in every
run's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mock only the abort event's wait in second-look tests

The delete-backstop and custom-cover cleanup tests patched
threading.Event.wait for every Event in the process. Thread.start()
waits on an Event too, and the first multiprocessing.Queue put in a
process starts the queue's feeder thread, so a stray wait() call landed
on the mock whenever that test was the first in its process to put on
LIBRARIAN_QUEUE. TestSecondLookBeforeDeleting then failed its
assert_called_once_with(5), alone or on a fresh xdist worker, and the
janitor test's assert_called() could pass without the second look ever
waiting.

Both now patch wait on the abort event the importer or janitor is
built with, which is the wait the code under test calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the Python suite in parallel with pytest-xdist

--numprocesses=auto --dist=loadscope spreads test classes across every
core and keeps each class on one worker, so setUpTestData runs once
per class. Each worker gets its own in-memory test database and, from
the previous commit's plugin, its own config dir and file caches.

--no-loadscope-reorder keeps pytest-django's TestCase-before-
TransactionTestCase order on every worker. loadscope's default sorts
scopes by size, which put BookmarkDedupeMigrationTests mid-run; its
flush then stripped the migration-seeded rows (ComicboxTaggingDefaults,
AgeRatingMetron) and left the comic sequence at 14 for the TestCases
that followed on that worker: 13 failures.

Locally on 12 cores: 239 s serial -> 75 s; -n 4 (the CI runner's
vCPUs) 87 s. Same 1668 passed, 1 xfailed and the same coverage TOTAL
as the serial run. -n0 runs serially.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop vitest linting and rewriting its snapshot

vite-plugin-checker ran a full eslint_d pass inside every vitest run.
The Lint job already lints the frontend, so the checker now loads only
outside the test environment.

reader-nav-button's only assertion on its initial markup was
toMatchSnapshot() against a gitignored file that --update rewrote on
every run, so it could never fail. It now asserts the link's tag, href,
title, disabled state and classes. --update leaves test and test:ci;
test:update regenerates snapshots on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* update devevnv

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Move the raw ciphertext read out of check_credentials into
_stored_credentials, taking check_credentials from C to A (5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit e09be6b into main Oct 19, 2026
13 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