Skip to content

v2.5.3 - #969

Merged
ajslater merged 349 commits into
mainfrom
develop
Oct 7, 2026
Merged

v2.5.3#969
ajslater merged 349 commits into
mainfrom
develop

Conversation

@ajslater

@ajslater ajslater commented Oct 7, 2026

Copy link
Copy Markdown
Owner
  • Features
    • Online tagging gains an Ask match mode that prompts for every match
      instead of writing any.
    • Admin Doctor tab: archive tools, PDF support, image codecs, database,
      libraries, watcher limits, config and package problems, each with its fix.
      Problems are also logged at startup.
  • Fixes
    • A missing or broken unrar no longer hides CBR comics from scans or deletes
      them from the library; they are listed as failed imports with the cause.
    • A broken PDF library disables PDF reading instead of stopping the server.
    • Metron logins by username and password tag again; a blank API key no
      longer overrides them.
    • Credential checks no longer compete with a running tagging job for
      Metron's rate limit.
    • Re-applying a chosen online match reuses the cached search.
    • Failed imports no longer vanish from the admin list after other imports.
    • Size searches accept kb, mb, gb and tb units and the >= and <= operators.
    • Codex shuts down cleanly on Python 3.12 and 3.13.
  • Performance
    • Docker image is about 165 MB smaller.

ajslater and others added 30 commits August 21, 2026 03:07
OPDS responses were cached without varying on User-Agent while the body
depends on it, so a client could be served a variant rendered for a
different one until the entry expired. UserAgentNames switches facet
emission (FACET_SUPPORT), download mime types (SIMPLE_DOWNLOAD_MIME_TYPES),
order facet suppression (CLIENT_REORDERS) and absolute hrefs
(REQUIRE_ABSOLUTE_URL), and cache_page keys only on the URL plus the
headers named in Vary.

Add User-Agent to the existing vary_on_headers in opds_cached, which
covers every wrapped v1 and v2 feed, start, manifest and opensearch
route. Vary already includes Cookie, so per-session keys existed anyway
and this barely fragments the cache further; it also corrects what
intermediary caches are told. Cover routes keep the narrower vary since
covers don't depend on the client, and the static authentication
document is left alone.

Adds tests/test_opds_cache.py asserting the Vary header names User-Agent
and that a feed primed by one client isn't replayed to another. Both
fail without the fix.

Fixes #811


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

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

OPDS1TemplateEntrySerializer declared the field as `credits`, but the
entry object exposes `contributors` and the template iterates
`entry.contributors`. The template renders serialized data, so the
mismatch meant DRF looked for a `credits` attribute on the entry, found
none, and skipped the field: `credits` is read_only and not required, so
get_attribute raises SkipField and the key is omitted with no error.
`contributors` was never declared and so never serialized, leaving the
template's contributor loop iterating nothing.

The result is that comic credits which are not writing credits -- artists,
colorists, letterers, everyone outside AUTHOR_ROLES -- have never appeared
in a v1 feed. `<author>` was unaffected because all three layers agree on
that name. Atom allows zero or more `atom:contributor` in an entry, so the
schema tests could not catch it either.

Rename the field to match the entry property and the template. The payload
shape was already correct: get_credit_people and its batched variant return
objects with .name and .url, exactly what OPDS1CreditSerializer expects.

Adds tests/test_opds_contributors.py, which seeds a Writer and a Colorist
and asserts each lands in its own element. Without the rename the author
assertion still passes and the contributor one fails, which is the shape
of the bug.


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

Co-authored-by: Claude <noreply@anthropic.com>
Panels on iOS added OPDS facet and sort support in 3.13.0, so add it to
UserAgentNames.FACET_SUPPORT. Its CFNetwork style UA parses to "Panels"
through get_user_agent_name, so the existing exact-name match works.

Two more fixes came out of testing that allowlist.

Facet capable clients received the facets twice. The gate in the v1 feed
`entries` property had been commented out, so facets() ran unconditionally
and its OPDS1Link objects were appended to the entries list alongside the
real facet links from _links_facets. OPDS1TemplateEntrySerializer drops
every field those links don't have, so each one rendered as a dead entry:
empty id, no links, unclickable. Restore the gate so the fake navigation
folders are emitted only for clients that can't read facets.

The opds:facetGroup attribute carried internal query parameter names, and
clients like Panels show them verbatim as filter menu headings. Add a
display_name to the FacetGroup dataclass and emit that instead, so the
headings read "Order By", "Order Direction" and "Views". The query param
still drives hrefs and active-facet detection. facet_group also becomes a
CharField; it was typed as a collection-name ChoiceField whose choices
never included any value actually assigned to it.

Adds tests/test_opds_user_agent.py covering both facet variants, the
display names, and User-Agent parsing. Each test fails without its
corresponding fix.

Fixes #810


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

Co-authored-by: Claude <noreply@anthropic.com>
Panels' facet support is per platform and the platforms share one UA
name: the macOS build (951) does not render OPDS facets while iOS builds
(952 and later) do. Matching FACET_SUPPORT on the name alone sent macOS
Panels facet links it can't render, and with no fake nav folders either
it had no sort UI at all.

get_user_agent_name now also returns a build number, parsed from the
token right after the first slash for clients listed in _BUILD_UA_NAMES
(Panels only today). The auth mixin memoizes the pair and exposes it as
user_agent_name and user_agent_build, so existing name consumers are
unchanged. use_facets requires the client's build to meet
UserAgentNames.FACET_SUPPORT_MIN_BUILD (Panels: 952) in addition to name
membership; a missing or unparseable build fails the floor, falling back
to the fake nav folder sort that works on every client. Clients without
a floor, like kybooks, are unaffected.

No cache change needed: the response cache already varies on the full
User-Agent header, so builds 951 and 952 key separately.

Updates tests/test_opds_user_agent.py: the iOS constant moves to build
952, a new test pins that build 951 keeps the nav folder sort and gets
no facet links (it fails against the name-only gate), and the parse unit
tests cover the (name, build) pair including unparseable builds.


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

Co-authored-by: Claude <noreply@anthropic.com>
Django 6.1 deprecates the discrete EMAIL_* connection settings
(RemovedInDjango70Warning at django.setup()), and defining MAILERS makes
reading the old names an AttributeError.

- Replace the eight deprecated settings with an EMAIL_CONNECTION_OPTIONS
  dict (TOML/env layer, keyed by EmailBackend constructor kwarg) plus a
  MAILERS declaration pointing at the DB-aware DBEmailBackend.
- get_email_connection_kwargs / get_email_from_address coalesce the
  EmailSettings DB row over EMAIL_CONNECTION_OPTIONS instead of the
  removed settings.
- DBEmailBackend defaults its mailer alias so direct construction never
  hits the SMTP parent's pre-MAILERS settings fallback.
- The admin test-send view builds the backend directly and calls
  send_messages(), dropping deprecated get_connection() and
  EmailMessage(connection=...).
- Tests override MAILERS + EMAIL_CONNECTION_OPTIONS instead of
  EMAIL_BACKEND/EMAIL_HOST.


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

Co-authored-by: Claude <noreply@anthropic.com>
#817)

The build >= 952 floor was built on a wrong premise: Panels build
numbers interleave across platforms. Real iOS builds run lower than the
macOS build - 942 (reported in the field) and 950 (issue #810's
reporter) both render facets natively, while macOS 951 does not - so no
floor can separate them, and 952 shut real iOS users out of facets,
handing them the fake nav folder sort instead.

Replace FACET_SUPPORT_MIN_BUILD with FACET_BLIND_BUILDS, a per-client
frozenset of known facet-blind builds (Panels: {951}). use_facets now
refuses only those builds; every other build - unknown and unparseable
ones included - gets facets, which is the pre-gate behavior that worked
on iOS. A future facet-blind macOS build must be added to the set as
discovered; until then it receives facets it ignores, the pre-gate
status quo.

The iOS test constant moves to the field-reported build 942, an
unparseable-build UA joins the facet-capable cases to pin the
facets-by-default behavior, and the macOS 951 test still asserts the
nav folder fallback. The 942 and unparseable cases fail under the old
floor gate and pass under the denylist.


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

Co-authored-by: Claude <noreply@anthropic.com>
Writing tags to a CBR converts it to CBZ (comicbox repacks unwritable
archives) and, with delete_original, removes the .cbr. The rename pass
then opened the dead .cbr path and failed with 'does not exist', and the
comic's row stayed pointed at the removed file. The converted CBZ is a
new inode, so neither the watcher's nor the poller's inode move
detection could ever pair old to new: the row was deleted and recreated
as a fresh comic, losing bookmarks.

Consume comicbox 4.8.5's WriteResult.final_path so the writer knows
where each archive ended up. The rename pass now chases the written
file to its post-conversion path, and a new conversion-aware DB sync
replaces the split rename/unwatched-reimport enqueues: a delete_original
conversion is recorded as a targeted move — for watched libraries too,
since the watcher cannot pair it; its later add/delete events reconcile
against the already-moved row. Conversions that keep the original leave
the row alone and report the new CBZ as a created file. Pure renames
and in-place writes keep their existing watcher-aware behavior.

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

* update deps

* fix(tagging): follow CBR->CBZ conversion through rename and DB sync

Writing tags to a CBR converts it to CBZ (comicbox repacks unwritable
archives) and, with delete_original, removes the .cbr. The rename pass
then opened the dead .cbr path and failed with 'does not exist', and the
comic's row stayed pointed at the removed file. The converted CBZ is a
new inode, so neither the watcher's nor the poller's inode move
detection could ever pair old to new: the row was deleted and recreated
as a fresh comic, losing bookmarks.

Consume comicbox 4.8.5's WriteResult.final_path so the writer knows
where each archive ended up. The rename pass now chases the written
file to its post-conversion path, and a new conversion-aware DB sync
replaces the split rename/unwatched-reimport enqueues: a delete_original
conversion is recorded as a targeted move — for watched libraries too,
since the watcher cannot pair it; its later add/delete events reconcile
against the already-moved row. Conversions that keep the original leave
the row alone and report the new CBZ as a created file. Pure renames
and in-place writes keep their existing watcher-aware behavior.

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

* fix(tagging): record watched-library renames instead of inferring them

A rename in a watched library enqueued nothing, on the premise that the
watcher's inode pairing would detect the move on its own. That premise
does not hold for PDFs. pdffile's save() writes a temp file and
replace()s it over the original, so an "in-place" PDF tag write leaves a
new inode at the same path. The following rename then reaches the
watcher as an unpairable delete+add — detect_moves compares the row's
stale stored inode against the renamed file's fresh one — and the Comic
row is deleted and recreated, losing bookmarks and read state. Even a
same-inode archive goes unpaired when its delete and add land in
different watcher batches.

Enqueue the targeted move for watched libraries too. Codex performed the
rename; it should state the move rather than leave the watcher to
re-derive it from inodes. Scoping this to PDFs by file_type would freeze
a snapshot of comicbox's per-format write strategy from another repo:
its CBZ path patches the zip in place today, but is non-atomic, and an
obvious future move to tmp+replace would silently reintroduce this bug.

Duplicating a move the watcher does pair is safe: whichever copy lands
second is dropped by _remove_file_move_collisions for an occupied
destination, or matches no source row in _bulk_comics_move_prepare. As
with a conversion, a watcher delete that lands first degrades to the old
delete+recreate — never worse.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…batch (#823)

A tag write that converts archives (CBR->CBZ with delete_original) records
the conversion as one targeted move ImportTask enqueued when the whole
batch finishes. A batch long enough to force a mid-batch watcher flush
(60s of continuous activity) or to catch a poll gets that scan's task
enqueued first, and ScribeThread's PriorityQueue breaks ties between equal
priority ImportTasks by enqueue time. The scan ran first, deleted the
comic rows by their now-dead paths -- cascading bookmarks away -- and left
the move with no source row. Short writes were safe; only long ones lost
data, which the conversion fix documented as best-effort.

Register every path a pending move passes through (the DB source, the
interim converted archive, the destination) in a process-local registry,
and drop registered paths from a task's created/modified/deleted sets in
init_apply, the importer's first phase. A task that carries the registered
move reconciles it, so it releases the guard and is exempt from it; the
exemption is computed from the task rather than from the release so an
unappliable move cannot cost a task its own paths. Ordering stops
mattering: the scan becomes a no-op for those paths whenever it runs, and
this covers the poller as well as the watcher.

Guarding creates matters as much as guarding deletes. Without a rename,
the move's destination is the interim CBZ, and a scan that imported it
first would leave the move to be dropped as a destination collision --
stranding the original row, bookmarks and all, on the dead path.

Port the poller's _is_move_compatible file-type and size check into the
watcher's inode move detection. A bulk conversion mass-frees CBR inodes
while mass-creating CBZ files, so on an inode-reusing filesystem a new CBZ
can be handed the inode a different comic's CBR just released, re-pathing
one comic's row onto another comic's file. The size check is waived when
the same batch also reports the source as written, which is the in-place
write-then-rename flow whose stored size is legitimately stale. Since
#822 codex states its own renames rather than leaving them to be paired,
so the check now only gates moves inferred for third-party changes.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The online tagging status table showed a single Status and a single
Source per comic, which could not answer the question the admin actually
has during a two-source scan: which source is in which state. A comic
matched by Metron while Comic Vine sat out a rate limit read as one
"Matched" row with one source chip, and a source's rate-limit wait was
only visible in the strip above the table.

Replace both columns with one column per source the session selected, in
priority order, so each row reads across as that comic's per-source
state: Matched, Looking up, Waiting (rate limited), No match, Needs
review, User matched/skipped, or Error. A source the session did not
select gets no column at all; the columns are driven by batch.sources,
which the snapshot already carried and the frontend had never read.

comicbox already emits per-source events carrying both a path and a
source (SearchStarted, AutoWritten, NoMatch, Skipped, PromptDeferred);
OnlineTagOutcomeStats dropped all but two of them in its catch-all arm.
Fold them into source_status_by_path and ship it per row as
source_statuses, retiring won_sources, which only ever populated for
matched rows. FileFinished/FileError clear a still-searching cell, since
a search that raises is swallowed upstream without an event.

Waiting is projected rather than folded: it comes from the scan's
per-source retry deadlines, now sharing one predicate with the sources
strip so a source can never read as throttled in one place and free in
the other. User resolutions had to ride on the resolution record instead
— the prompt-apply path builds its session without an event hook — so
that record becomes {pk: {status, sources}}, merged per source because
merge-all-sources can raise a prompt from each source for one comic, and
the old last-write-wins shape dropped the first. Records written before
this change still overlay, without a source to attribute them to.

Cells with no recorded state describe themselves rather than showing a
bare em-dash: a first-wins source the scan never needed reads "Skipped"
(only when the row has real cells and merge-all is off, so it cannot
claim a source ran when that is unknowable), an unreached source on the
in-flight comic reads "Queued", and every status carries a tooltip. The
dash remains only where the state is genuinely unknown, and says so.

The status vocabulary moves to its own dependency-free module: both the
event fold and the snapshot builder need it, and importing one from the
other closed a cycle. The three duplicated Metron Cloud / Comic Vine
label maps collapse into one shared module.

The same table made a pre-existing rate-limit bug user-visible, fixed
here too. Per-source retry deadlines were cleared wholesale whenever any
comic finished, on the theory that a result proves the wait is over --
but the deadlines belong to a source, not to a comic. Worse, nothing
cleared them at scan end: comicbox aborts its retry sleep on cancel, so
pausing mid-wait strands a deadline that is still in the future, and
run_session then freezes it into the snapshot the admin keeps looking
at. The paused table counted down and stuck on "retrying...", against a
scan running nothing.

Leave per-source deadlines alone on a per-comic result (they expire on
their own epoch, which readers already filter) and clear them in the
pass runner's finally, which runs before the frozen snapshot is
published. A source that reports an outcome releases its own deadline
early; SearchStarted deliberately does not, since it fires before the
request that hits the limit. An exhausted retry budget drops the
countdown instead of advertising a retry that will never come, and
deactivate_snapshot disarms the strip for the crash path that skips
every finally. The frontend renders no countdown on an inactive snapshot
regardless, covering snapshots cached by older versions.

Also fix an adjacent estimate bug in the same function: the stalled eta
omitted merge_all_sources, so under merge-all the time remaining shrank
the moment a rate limit fired and jumped back on the next result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	NEWS.md
#	codex/librarian/scribe/tag_writer.py
#	tests/test_tag_writer_rename.py
The tag editor had no inputs for four fields codex already stores on Comic
and shows read-only. All four were missing from _CANONICAL_TO_EDITOR, the
map FORMAT_FIELD_SUPPORT derives from and every input gates on:

- date (year/month/day), offered by both write formats
- country (ComicInfo), collection_title and alternative_issue (MetronInfo)

Also drops a dead reading_direction entry that no transform offers a
canonical key for; ComicInfo reaches it through manga.

Composite keys follow the contract issue and community_rating already use:
comicbox update mode replaces a top-level key wholesale, so every surviving
part rides along whenever any part changes, a cleared part drops out of the
replacement, and only a fully empty value emits a delete key. buildPatch
bounds the date parts because nothing gates Save on the field rules and
comicbox writes any year it is handed into a positive small int column.

Fixes country and language seeding while here: their serializers map alpha-2
to the long English name, but the choice lists are keyed by the code comicbox
writes, so the current value matched no item and re-picking it flipped the
panel dirty for a no-op edit.

Known, and shared with the other composite keys: a MetronInfo StoreDate does
not survive a date edit (comicbox rederives cover_date but never store_date,
and codex keeps no column to resend it), and a multi-comic date edit erases
parts that differ across the selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Comic column carries width 100% so the filename fills every spare
pixel before truncating, which squeezed the per-source status columns
down to their narrowest word and stacked "Metron" over "Cloud" in the
header even on a viewport with room to spare.

Mark those columns nowrap so their full header text counts toward their
intrinsic width. Comic yields the space back and truncates its filename
instead, which is what its max-width:0 ellipsis already exists to do.

Use Vuetify's own column property rather than a custom rule: it styles
the header and body cells consistently and needs no :deep() selector
competing with the vuetify-components cascade layer. Its rule pairs
nowrap with an ellipsis, so a viewport too narrow for the full name
clips it rather than ever stacking it again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tagging cache these tests exercise is the running install's, not a
fixture of their own: pytest sets no CODEX_CONFIG_DIR, so caches["tagging"]
resolves to config/cache/tagging. The autouse fixture cleared the prompt,
resolution and resume keys but never the snapshot, so the deactivate test
left its fixture behind — and the admin Tagging tab rendered it as a
phantom paused session with two queued comics that Resume answered 400,
because the fixture had cleared the resume descriptor it needed.

Clear the snapshot on both sides of the fixture with the rest. A full test
run now leaves every tagging key empty.
Resume needs the stored descriptor naming the comics a scan never reached
and the settings to re-run them with, but the status table offered its
button on the snapshot's own resumable flag, which reports only that
comics were left unprocessed. The two are independent keys and can
disagree: run_session cleared the descriptor as its first act and left the
next publish to rewrite it, yet the first publish is throttled four
seconds, so a daemon killed in that window left a snapshot full of queued
comics with nothing to resume from. The button then answered 400.

Record the batch's remainder up front instead of merely clearing the prior
one, which overwriting does anyway; each publish narrows it as comics
finish, and a normal finish empties it. Derive resumable at read time from
the descriptor actually being there, so a session that cannot resume reads
as finished rather than advertising a button that fails.

The failure was also silent -- the click reset a flag and swallowed the
error, so nothing reached the admin. Report pause and resume failures
through the common store's snackbar, as the tag launcher already does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng (#824)

* fix(tagging): rename archives with their own file extension

``ext`` is a metadata field, not the file's suffix, and codex's read
config deletes it — so comicfn2dict fell back to its "cbz" default and
every PDF/CBR/CBT/CB7 was renamed to a name claiming to be a zip. The
admin preview showed the same wrong name.

Codex now performs the rename itself. Comicbox's ``rename_file`` derives
its own destination and cannot be handed a corrected target, so owning
the move is what makes the suffix correctable. A rendered name that is
nothing but an extension would create a hidden file, so it is treated as
no name at all.

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

* fix(watcher): match path prefixes on directory boundaries

Both the deleted-directory expansion and the library attributor compared
bare string prefixes, so any two paths where one name merely began with
the other were treated as parent and child.

Deleting a watched folder therefore expanded into every sibling tree
sharing its leading name — "Batman" collecting all of "Batman Beyond" —
and those comics were deleted, with no paired add to rescue them and
their bookmarks cascading away while the files were still on disk.
The same bug filed a sibling library's events under whichever library
happened to be a string prefix of it.

Terminating each prefix with a separator restores the boundary. The
library root itself still matches its own events.

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

* fix(librarian): keep the scribe priority queue totally ordered

Two ways a scribe task could raise TypeError inside the queue:

``SHUTDOWN_MSG`` was a bare int where every real item is a
``(priority, timestamp)`` tuple, so stopping the thread with any task
still queued raised comparing int to tuple — aborting the daemon's
shutdown loop before the remaining threads were ever told to stop.

Equal priorities fell through to comparing the ScribeTask dataclasses,
which define no ordering. Timestamps tie more readily than they look
(they are truncated, and a clock can step backwards), and the loser was
a task dropped in the routing thread. A monotonic counter now closes the
tuple so two entries can never compare equal.

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

* fix(tagging): dedupe a merged online tag scan by path

``_merge_task`` tested each candidate path against ``path_to_pk``'s
*values*, which are pks — a Path never equals an int, so the guard never
excluded anything. Starting a second scan whose selection overlapped the
running one (re-picking a folder to catch additions) queued every shared
comic again: an inflated total, duplicate lookups against rate-limited
sources, and a second write of the same file.

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

* fix(importer): say what the filesystem settle timeout actually does

The warning told the admin to poll again once copying finished, implying
the task had been abandoned, but only ``init_apply`` returned early — the
import ran on regardless, and skipped starting its statuses on the way
out. Keep importing (abandoning the task would drop the events entirely
on a watched library that isn't also polled) and describe that instead.

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

* docs(news): rename, watcher prefix and queue fixes in v2.2.11

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

* fix(tagging): let comicbox rename, with the extension stated

Supersedes the previous commit's approach. Taking the rename away from
comicbox fixed the name but duplicated comicbox's job, broke the
invariant that codex's collision pre-check targets the exact path
``rename_file`` will use, and would have been dead weight the moment
comicbox renders the extension itself.

The real defect is the input, not the renamer: ``ext`` is a metadata
field, and the read config deletes it, so comicfn2dict fell back to its
"cbz" default. Neither half of the fix works alone — un-deleting the key
leaves it unset, and stating it under the read config gets it deleted
after the merge — so renaming uses a config that keeps ``ext`` and
states the archive's real suffix as metadata. That outranks any
extension a third-party tagger embedded in the archive too.

The admin preview derives its name the same way, so it can no longer
promise a name the rename won't produce.

Covered against a real archive (a CBT repacked from the example CBZ),
since whether the rendered extension is right now depends on what codex
hands comicbox — something the test double cannot exercise.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ajslater and others added 29 commits September 29, 2026 16:27
After a saved view was picked from the settings drawer's "Load Saved
View" combobox, the view loaded but the menu popped back open with
focus still in the input.

Vuetify closes the menu on select and keeps focus in the combobox.
When the view's settings land, the component's deep settings watcher
clears the combobox model. VCombobox's model watcher then sets its
search text to '', and its search watcher opens the menu whenever the
field is focused and the menu is closed.

The watcher now blurs the combobox before clearing a picked view, so
the name still clears and the menu stays shut. It only blurs when the
model holds a picked view (an object with a pk). Every browse page
load re-sets settings.breadcrumbs, which fires the same watcher. An
unconditional blur closed a menu the user had opened while a page was
still loading.

Checked in a real browser: mouse and keyboard picks, a view whose
settings match the current ones, and opening the menu mid page load.
Also checked merged with the unpushed per-view trash icon branch: a
pick from its item slot, and its Delete View dialog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Add alt text to the logo images in README and NEWS.
- Point in-page links at the ids readthedocs generates and add matching
  <a name> anchors so the same links work on GitHub, whose emoji-heading
  slugs differ.
- Link the Windows doc as docs/WINDOWS.md so it resolves on GitHub too.
- Drop the Troubleshooting heading's self-link.

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

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

* ci: let the deploy job publish to PyPI with trusted publishing

Grant id-token: write to the deploy job, which runs devenv-pypi after the
image manifest. devenv-pypi now publishes with PyPI trusted publishing
once the PYPI_TOKEN secret is deleted, and that needs the job to be able
to get a GitHub identity token. While the secret exists it still
publishes with the token. A new test pins the permission.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* update comicbox

* fix(reader): 404 a page past the end instead of caching an empty 200

comicbox 5.2.2 made get_page_by_index() return None for an index past
the last page; before, it raised StopIteration, which surfaced as a 500.
The page view turned that None into b"" and served it as a 200
image/jpeg, and both page routes (v4 reader and OPDS) mark 2xx
responses public for a week, so clients kept the empty image.

Raise NotFound on None instead. cache_control_2xx leaves 404s
uncached.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
"!docker/debian.sources" names a file that isn't there: the Dockerfile
copies debian.sources from the context root, and there is no docker/
dir. It also sat before "docker*", which re-excluded it, since Docker
lets the last matching line win. No pattern excludes dist, so "!dist"
had nothing to re-include.

buildx gives the same build context, 1261 files, with or without them.

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

* fix(deps): declare packaging, which codex.version imports

codex/version.py has imported packaging.version since 446cab8 (v2.2.10),
but packaging was never a dependency and nothing in the runtime closure
pulls it in. Installs only worked where something else had dragged it in:
the Docker image got it from the builder's pip/poetry tooling. A fresh
`uv pip install codex==2.5.1` crashes on import with
ModuleNotFoundError: No module named 'packaging'.

An AST scan of every import under codex/ against a runtime-only venv finds
nothing else missing (icecream and schema_graph are dev-guarded).

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

* perf(docker): ship a uv.lock-pinned venv; drop builder and apt leftovers

The final image was python-debian plus `COPY --from=wheel-installer
/usr/local /usr/local`, which shipped the whole nikolaik builder: node,
npm, uv, poetry and pipenv (~330 MB), and replaced python-debian's own
interpreter and stdlib.

- Runtime deps now install into /opt/codex, a venv on python-debian's
  interpreter, pinned from uv.lock via `uv export` instead of re-resolved
  from pyproject ranges. Only that venv is copied into final. PATH gets
  /opt/codex/bin so `docker exec codex python` still works.
- The apt install of `ruamel.yaml.clib` was parsed as a regex and
  installed python3-ruamel.yaml.clib plus Debian's entire python3.13.
  The image codec libraries were dead weight too (the Pillow and PyYAML
  wheels bundle their own). final installs only curl, libstdc++6 and unrar.
- Bytecode is compiled at install time instead of purged. The app runs as
  abc, which can't write .pyc files, so every process recompiled on
  import: `import codex.asgi` as abc drops from 3.3 s to 1.1 s.
- pycountry's gettext locales and every non-English Django/allauth/DRF
  locale are removed: LANGUAGE_CODE is en-us and there is no
  LocaleMiddleware.
- A build-time smoke import fails the build, not container start, on a
  broken venv.

arm64 image: 1,380 MB -> 889 MB. Covers and first pages of a CBR, CBZ and
PDF are byte-identical to the old image's. Both platforms build.

tests/test_dockerfile.py pins the apt list, the no-regex-names rule, the
venv-only copy and kept bytecode. .dockerignore now admits the Dockerfile
so the test runs in the CI image.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(covers): stop the cover pool on Python 3.12/3.13

`CoverCreateThread.stop()` called `ProcessPoolExecutor.terminate_workers()`,
which only exists on 3.14. On the declared 3.12 floor (and 3.13) stopping
the librarian raised AttributeError; a type-ignore hid it. Probe for the
method, and cancel pending futures in `shutdown()` so the older Pythons
still drop the queued tail.

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

* fix(user_data): sqlite autocommit=True + explicit transaction()

The sidecar connection used legacy `isolation_level=None` autocommit, where
`with conn:` never issues BEGIN, so the schema + version stamp and
`_clear_sidecar`'s "single transaction" truncate were never transactions:
a failure part-way left a half-cleared sidecar. Switch to 3.12's
`autocommit=True` and run multi-statement writes through an explicit
BEGIN/COMMIT/ROLLBACK `transaction()`. Single-statement upsert/delete drop
their no-op `with conn:`. The backup's `VACUUM INTO` connection gets the
same kwarg swap.

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

* fix(importer): failed-import existence check compared a full path to basenames

`_query_failed_import_deletes` casefolded the whole path and compared it to
each sibling's casefolded basename, so it never matched: every untouched
failed import was marked missing and deleted after any import. Compare
basenames, case sensitively as the comment intends (`exists()` is case
insensitive on macOS).

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

* fix(search): size suffixes and >=/<= operators were unreachable

Both lookup tables were matched in insertion order by the first
`endswith`/`startswith` hit. `b` came before `kb`, so `size:10kb` parsed
`10k` as a number and raised; `>` came before `>=`, so `>=5` became `>`
with the value `=5`. The search filter swallows the error and silently
drops the term. Put the longer keys first and strip the operator with
`removeprefix`.

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

* fix(opds): parse ISO dates with datetime.fromisoformat; drop undeclared dateutil

`dateutil` reached the OPDS v1 entry only transitively (via `dateparser`).
`datetime.fromisoformat` (3.11+) reads the ISO 8601 forms Django and SQLite
emit. A string input now takes the same UTC isoformat path as a datetime
instead of returning a bare datetime, so `updated`/`published` are honestly
`str | None`; naive values are read as UTC, as Django stores them. The
`types-python-dateutil` stubs go with the import.

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

* refactor(workers): asyncio.timeout in WorkerPool.stop

One `asyncio.timeout` around the drain loop replaces the hand-kept deadline
and per-iteration `wait_for`. A timeout mid-`get()` drops only the getter,
never a queued worker, as before.

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

* refactor: itertools.batched for manual slice-chunk loops

The 17 `for start in range(0, len(seq), N): batch = seq[start:start + N]`
loops become `for batch in batched(seq, N):`; abort-event guards stay first
in the body. `tuple(...)` wrappers that existed only to allow slicing go,
except where the snapshot or a type-ignore still earns its keep. Batches are
tuples, which every consumer (`__in` lookups, `in_bulk`, cursor params,
`len()`) already accepts.

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

* refactor: Path.walk, is_relative_to, datetime.UTC, removeprefix, cache, frozen dataclasses

- watcher `expand_dir_added` walks with `Path.walk()` (3.12). Unfollowed
  directory symlinks now land in `filenames` instead of `dirnames`; they
  still yield no event unless named like a comic archive.
- `is_relative_to` replaces two try/`relative_to`/except ValueError probes.
- `datetime.UTC` replaces `ZoneInfo("UTC")`.
- `str.removeprefix` strips the ASGI root path.
- `functools.cache` for the zero-arg OPDS schema loaders.
- `BookmarkKey` is `frozen` (it already wrote through `object.__setattr__`
  and keeps its explicit hash/eq); `_LastRouteKey` gains `slots`.

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

* refactor(typing): PEP 695 generics, type statement, Self, enum containment

- `_coalesce[T]` and `timed_step[T]` replace `Any` round-trips.
- `cache_control_2xx` gains a `[**P, R: HttpResponseBase]` signature, the
  codebase's first ParamSpec.
- `type Resolution = ...` for the online-tag answer tuple.
- `-> Self` on `ComicACL.for_user` and `SidecarStore.in_memory`.
- 3.12's `value in EnumClass` replaces three frozensets of member values.
  Two of them gate telemetry buckets: the vocabularies are the same closed
  Codex enums as before, so nothing new is collected.

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

* refactor: dict union, monotonic clocks, f-strings

- `a | b` replaces `{**a, **b}` where both sides are plain dicts. The admin
  URL action maps and the reader settings lookups keep the unpack literal:
  DRF's invariant `dict[str, str | ViewSetAction]` parameter and the
  base view's `FILTER_ARGS: Mapping` need the literal's inferred type.
- Durations time with `perf_counter()` and the import write-wait deadline
  and mock-comic progress interval with `monotonic()`, so a wall-clock
  step can't skew them. The importer keeps its wall-clock `start_time`
  (stamped into the DB) and gains a `started` counter for the finish log.
- f-strings replace `+ str(...)` concatenation in model reprs and
  `get_sort_name`, the last a PEP 701 nested-quote f-string.

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

* refactor: Notifications StrEnum; cached_property memo properties

`Notifications` becomes a `StrEnum` like `ChannelGroups` and
`WebsocketMessages`, dropping `.value` at all 28 sites. Members hash, compare,
pickle and `json.dumps` as their string values, so the notifier dedup, the
typed-payload lookup and the websocket wire format are unchanged.

27 hand-rolled `if self._x is None:` memo properties become
`functools.cached_property`, dropping their backing attributes and the
`__init__` lines (and four whole `__init__`s) that only seeded them:

- `params` on the browser view and its three load-only overrides;
  `set_params` assigns the cached value directly.
- `model_collection` and its `BrowserView` override.
- `is_admin`, losing `init_is_admin` (only ever run from `__init__`).
- `user_agent_name`, `admin_flags` (the publications preview still shares
  it by assignment), and the OPDS facet, validation, ordering, breadcrumb
  and stats memos.

`OPDS2LinksView.num_pages` stays a plain property, reading the memoized
`collection_and_books`, because it overrides a property.

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

* chore: drop 59 vestigial from __future__ import annotations

12 codex and 47 test modules have no annotation that needs deferring: no
`TYPE_CHECKING`-only name and no forward reference in a runtime-evaluated
annotation. Each was checked by removing the import, running ruff
(TC004/F821) and importing the module on Python 3.12, where annotations are
still evaluated eagerly; the three perf scripts, which need the debug-only
silk stack to import, were checked statically. The other 42 modules keep the
import because ruff moved their annotation-only imports under
`TYPE_CHECKING`.

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

* chore: pyproject deps/classifiers, reportImplicitOverride=error, docs state the floor

- Drop `tomli` (its `< 3.11` marker is always false on the 3.12 floor;
  every TOML reader uses `tomllib`) and `typing-extensions` (no direct
  imports) from the runtime dependencies.
- Classifiers name Python 3.12, 3.13 and 3.14, and Django 6.0 instead of 5.1.
- basedpyright fails on a missing `@override` (already zero violations).
- The README and the Windows guide state the Python 3.12 minimum.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode (#968)

* Adapt to comicbox 5.3.0: doctor report, scan every suffix, probe() credentials

comicbox 5.3.0 ships a doctor (run_checks) that reports whether the host can
read each archive format, has the image codecs cover matching needs, and
whether its config and package pins are in order, with a fix per problem.
Codex now runs it: problems are logged once at startup after loguru is up,
GET /api/v4/admin/doctor serves the rows, and the admin Jobs tab ends with a
Doctor section with a Re-check button. The Online section is left out
because codex keeps credentials in its own database and checks them on the
Tagging tab. The Docker final stage runs `comicbox doctor -q` as its smoke
test, which proves unrar extracts, pymupdf loads and every pin is satisfied.

The scanner no longer drops .cbr or .pdf when comicbox says the tool is
missing. The poller's database snapshot lists every row, so an excluded
suffix made every existing CBR or PDF row look deleted the day unrar broke,
and 5.3.0's probe test-extracts a member, which fails on more hosts than the
path check it replaced. Suffixes derive from FileTypeEnum, shared with the
janitor; an unreadable archive fails its import with comicbox's reason.

The credential validator uses OnlineSource.probe() instead of hand-rolled
mokkari and simyan calls: Metron's goes through comicbox's rate gate and
Comic Vine's counts against the real shared bucket. Adopting it exposed that
_build_credentials passed an unset Metron key as "", which mokkari turns into
a blank Bearer header that replaces the username and password, so legacy
logins failed on every real run while the old validator passed them. Unset
fields are now None.

Also: the chosen-match replay passes codex's online config so it reads the
cache the search filled; the reader imports pdffile lazily behind
is_pdf_supported() so a broken pymupdf disables PDFs instead of failing
server start, as comicbox does; str() of a StrEnum MatchMode is its value.

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

* Doctor on the Stats tab, with codex's own checks

Move the Doctor section from the Jobs tab to the top of the Stats tab,
above the Platform readout it extends: Jobs is for actions, and an admin
asking what this install is goes to Stats.

codex/doctor is now a package. checks.py adds a "Codex" section of rows,
in comicbox's row shape so the panel and the startup log treat them alike:

- database: SQLite version, FTS5 (proved with a temp virtual table, since
  not every build records compile options) and the journal mode in effect.
- config dir: writable, with free space; warns under 1 GiB, since the
  database, backups, logs and cover cache all grow there.
- one row per library: missing, the unmounted docker volume sentinel,
  unreadable, not writable unless read only, or empty.
- watcher: Folder rows under event-enabled libraries against the kernel's
  inotify watch limit on Linux, with a sysctl to run on the host.
- credentials: the stored tagging credentials read raw through Cast so the
  field's converter cannot hand back ciphertext, decrypted with the live
  key; a changed key file is otherwise invisible until a run fails.

The view runs the report through channels' database_sync_to_async now
that it queries the database.

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

* Give the doctor its own admin tab

The report opened the Stats tab, which is the telemetry preview; a health
report deserves a page of its own. Doctor is the last tab in the bar, at
/admin/doctor. The heading is the verdict, "No problems" or "N problems",
coloured by it, with the host line under it and Re-check on the right, so
the one thing an admin came to read is the first thing on the page. The
Stats tab is as it was.

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

* Require comicbox 5.3.1 and offer its Ask match mode

comicbox 5.3.1 accepts MatchMode.ASK from a session that has a prompt
handler or defers its prompts, which codex always supplies. Ask auto-writes
nothing and prompts for every match: review everything.

The match modes now derive from comicbox's enum instead of a hand-written
copy, on the model (migration 0056 adds ask to the choices) and in the UI
choices. The start endpoint validates the mode with a ChoiceField, so a
bad one is a 400 here rather than an error inside the librarian. The
launcher and the Tagging tab describe Ask in their hints.

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

* Doctor tab: comicbox's rows and codex's own under their own headings

The report carried codex's checks as one more section among comicbox's.
They answer different questions, so the report now keeps them apart:
DoctorReport holds comicbox's rows and codex's rows as two tuples, the API
returns them as two lists, and the tab renders each under its own heading,
comicbox's grouped by section and codex's flat. The table is its own
component so both headings share one look.

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

* update deps

* Clear ty's diagnostics

`make ty` runs apart from `make lint`, and 25 diagnostics had collected,
all in code untouched by this branch. Most sit on lines that already waive
the matching pyright rule for the same django-stubs gap (implicit `<fk>_id`
attributes, nullable foreign keys, reverse relations), so they gain the ty
twin. Two are real fixes: the foreign-key query built its field list with
filter(bool, ...), whose signature widens the elements to object, which
values_list refuses; a comprehension keeps the type. The stats view's
permission override cannot satisfy both checkers from a class that inherits
DRF's instance-variable declaration and the admin mixin's ClassVar, so it
waives ty's override rule and says why.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@ajslater
ajslater merged commit a6695dd into main Oct 7, 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