Skip to content

v2.2.11 - #829

Merged
ajslater merged 135 commits into
mainfrom
develop
Aug 26, 2026
Merged

v2.2.11#829
ajslater merged 135 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner

v2.2.11

  • Features

    • Edit a comic's publish date in the tag editor. Year, month and day are
      separate, so a year-only comic stays that way. Editing a date rewrites the
      whole date tag: a MetronInfo StoreDate does not survive it, and a
      multi-comic edit overwrites parts they disagree on.
    • Country, Collection Title and Alternative Issue are editable too, having
      only ever been displayed.
    • Redesigned the Admin Tagging Status table to be more informative.
  • Fixes

    • Bookmarks and read progress survive tag writes. Comics are renamed before
      they are written and the database moves with the file, so a rename, a CBR
      conversion, or a library scan landing mid-batch can no longer lose them.
      PDFs lost them every time before; other formats occasionally.
    • Comics are no longer deleted while their files are still on disk. One
      replaced in place, by a tool that removes and rewrites it, is re-read
      instead of deleted and re-added as a new comic.
    • A library that has gone missing, like an unmounted share or volume, no
      longer has all of its comics deleted.
    • Paths that merely begin the same way are no longer confused for one
      another: deleting the folder "Batman" leaves "Batman Beyond" alone, and
      libraries at /comics and /comics-kids no longer claim each other's
      changes.
    • Renaming keeps each archive's own file extension. PDFs and unconverted
      CBRs were renamed to .cbz names.
    • Two comics that would end up with the same filename no longer overwrite
      each other; the second is reported instead.
    • Editing a comic's tags twice in a row no longer fails the second time with
      a "no such file" error.
    • Online tagging only offers Resume when a scan can actually resume, and
      reports a failed pause or resume instead of silently doing nothing.
    • A second online tagging scan no longer re-queues comics the running scan
      already has.
    • Deleting a folder refreshes the series and publishers it emptied, which
      kept listing comics that were gone.
    • The librarian shuts down cleanly with work queued, and tasks queued in the
      same instant no longer collide and lose one.

ajslater and others added 30 commits July 3, 2026 16:19
The Profile dialog's self-service password change posted only
oldPassword + password to /api/v4/auth/password/change, but that
endpoint is rest_registration's ChangePasswordView whose serializer
requires password_confirm (camelCased passwordConfirm) — so the
request 400'd with "passwordConfirm field is required".

The dialog already collects and validates passwordConfirm; forward it
in the changePassword payload, matching change-password-dialog.vue and
the register/reset flows. Add a regression test asserting the field is
sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ect.any

The @vitest/eslint-plugin valid-expect rule misclassifies expect.any() as
chai's `.any` flag chain and reports "unknown modifier". Disable the rule on
the one nested assertion with a documented reason rather than weakening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
comicbox 4.0.5 no longer applies the effort knob to Metron tagging, and
Metron's search is now a flat two-step (series_list + issues_list) that
match mode does not change.

- Remove the vestigial `effort` option (serializer, task, resume params,
  and test). It was collected by the API but never passed to comicbox's
  OnlineSession.
- Count estimate calls-per-comic per source: Metron a flat 2, Comic Vine
  keeps its per-mode 2/3/5. First-match-wins bills the costliest single
  source; merge sums per-source calls. Mirrored in the launcher dialog.
- Resume view drops unknown persisted params so a pre-upgrade `effort`
  key in the file-based cache can't crash the task rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two facilities codex hand-synced from comicbox now consume it directly:

- Source names: KNOWN_SOURCES and the task/serializer/frontend default
  lists derive from comicbox's canonical SOURCE_NAMES tuple instead of
  repeating {"metron","comicvine"} literals in four places. The frontend
  gets it through the tagging choices JSON (build-choices), so a new
  comicbox source propagates without hand-editing every site.

- Issue-id parsing: the two byte-identical trailing-int regex copies
  (stored_id_prepass, explicit_id) collapse into one
  issue_id.parse_issue_id built on comicbox's canonical PARSE_COMICVINE_RE.
  It honors the real Comic Vine 4-digit long-key rule instead of grabbing
  any trailing int; an unrecognized key returns None, which safely falls
  back to search / rejects the id rather than guessing wrong.

No user-visible behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "~N requests/comic" tail in the match-mode hints only describes Comic
Vine, whose calls scale with match mode; Metron is a flat two-step search
regardless of mode. Drop the tail from the base hints and append a
"~N Comic Vine requests/comic" suffix only when Comic Vine is an active
source, so a Metron-only run no longer shows a count that doesn't apply.
The number derives from the existing COMICVINE_CALLS_BY_MODE constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poller's DiskSnapshot._walk() called os.scandir() with no guard
around the directory open, so a single permission-denied folder (e.g.
a Synology /comics/#recycle bin) raised PermissionError that propagated
up and killed the LibraryPollerThread, aborting the scan of every other
folder (issue #795).

- Wrap os.scandir so an unreadable/vanished directory is logged and
  skipped instead of aborting the whole poll, and widen the per-entry
  guard to cover entry.is_dir(), which can also raise PermissionError.
  This matches the os.walk default-onerror behavior the watcher relies
  on.
- Register the OS/NAS metadata basenames the filters module already
  documented but never populated (@eadir, #recycle, __MACOSX,
  Thumbs.db, desktop.ini), so the walker skips the recycle bin entirely
  and NAS/OS junk never enters the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commit 9db6fe273622635700defb5c9015bf540630e40a
Merge: c8984b9ed ed38cb5
Author: AJ Slater <aj@slater.net>
Date:   Sat Jul 4 15:57:53 2026 -0700

    Merge branch 'develop' into online-estimate-consume-comicbox

commit c8984b9ede16f82f398501adb49c58d5428c168d
Merge: 2b2e63a35 cd84ed9
Author: AJ Slater <aj@slater.net>
Date:   Sat Jul 4 13:22:52 2026 -0700

    Merge branch 'develop' into online-estimate-consume-comicbox

commit 2b2e63a35013de0cd5593c8c5cadb360ffbd23ab
Author: AJ Slater <aj@slater.net>
Date:   Fri Jul 3 20:36:09 2026 -0700

    feat(onlinetag): consume comicbox 4.1.0 estimate; drop the codex copy

    Pin comicbox ~=4.1.0 and move the online-tag run estimate onto its
    comicbox.online_estimate.estimate_run() home:

    - estimate.py becomes a thin seam over comicbox: estimate_seconds()
      forwards to estimate_run().seconds and re-exports SOURCE_RATE_PER_MINUTE.
      The request/rate constants and math are deleted -- comicbox owns and
      tests them now.
    - The launcher dialog's per-source rates and per-comic request model derive
      from comicbox via a new tagging-estimate.json (choices/onlinetag.py,
      build-choices); only display labels stay in the component, so the JS
      estimate can no longer drift from the backend.
    - The codex estimate test slims to an adapter / re-export guard.

    Prep branch: the ~=4.1.0 pin does not resolve until comicbox 4.1.0 is
    published, so uv.lock is untouched and CI targets that shell out to `uv`
    will fail until then. Post-publish, run `uv lock`; the change was validated
    locally with the 4.1.0 modules installed into the venv.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdminOnlineTagResumeView.post crossed radon's C threshold once the resume
descriptor sanitization landed. Move that logic (sources tuple coercion +
dropping keys no task field accepts) into a module-level helper; the view
falls to rank B and reads more directly. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
matchModeHint read an undefined COMICVINE_CALLS_BY_MODE, throwing a
ReferenceError on every launcher-dialog render (and failing
tests/unit/launcher-dialog.test.js). Point at the real
TAGGING_ESTIMATE.comicvineRequestsByMode map that callsForSource
already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An un-nested overlay is silently ignored by confuse.
…y failures

Answering a deferred prompt fetched the chosen issue against the path
serialized into the prompt at scan time. When an earlier write for the
same comic ran with rename enabled (the comic's other source's prompt,
or a stored-id prefetch), that path was stale and the apply died with an
uncaught FileNotFoundError — after the prompt was already consumed, so
the admin's pick vanished with no feedback.

- _apply_resolution now re-reads the comic's path from the DB by pk;
  a missing row reports to the Tagging error panel instead of fetching
  a dead path.
- fetch/replay failures (ComicboxError, OSError) and non-resolving
  explicit ids now land on the Tagging error panel instead of only the
  log, since the pick can no longer be re-prompted.
- stored-id prefetch and tag_by_id also catch OSError so a vanished
  file degrades gracefully.
- regression test for the COMICBOX_CONFIG general-section overlay
  (un-nested loglevel/delete_keys were silently ignored, letting
  comicfn2dict remainders like "(0000)" leak into rename targets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): native OIDC login via django-allauth

Codex becomes an OIDC Relying Party (Authentik/Authelia) with a
config-gated login flow:

- [auth.oidc] TOML section + CODEX_AUTH_OIDC_* env overrides
- allauth apps installed unconditionally; behavior gated on
  AUTH_OIDC_ENABLED (all OIDC paths 404 when off)
- CodexSocialAccountAdapter: username linking (superusers included,
  documented trust boundary), optional email linking, claim-chain
  username mapping with sub-hash collision suffix, groups-claim sync
  to existing Django groups, admin-group grant/revoke, error
  redirects to the SPA (never an allauth template)
- Branded throttled init endpoint /api/v4/auth/oidc/login; allauth
  login/callback mounted at /sso/ (outside the namespaced API tree so
  allauth's internal reverses work)
- RP-initiated logout URL via cached discovery document using the
  spec's client_id parameter (no stored tokens needed)
- /session payload gains public oidcEnabled/oidcProviderName/
  oidcLoginUrl and authenticated oidcLogoutUrl
- Profile username locks per-user when an OIDC identity is linked
- OIDC failures reuse the failed-login log line format for fail2ban

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

* feat(frontend): SSO login button, RP logout, and sso-error page

- auth store: oidc admin flags, loginSSO() full-page navigation,
  logout() follows oidcLogoutUrl for RP-initiated logout
- SsoLoginButton shared by the login dialog (with divider) and the
  unauthorized lock screen
- /auth/sso-error route + page mapping backend error codes to human
  messages, with retry hidden for non-retryable codes

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

* docs(auth): OIDC setup guide + complete tinyauth forward-auth recipe

- README: native OIDC section (config table, redirect URI with prefix,
  Authentik/Authelia walkthroughs, identity-mapping and admin-linking
  trust warning, session-lifetime and OPDS caveats)
- README: full nginx auth_request recipe for tinyauth with header
  override hardening, Traefik/Caddy equivalents, and a forward-auth
  deployment checklist (OPDS + WebSocket gating, spoof test)
- schema test: allauth views stay out of the OpenAPI schema
- test typing fixes surfaced by basedpyright

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

* update deps

* fix(settings): nest comicbox loglevel/delete_keys under general section

An un-nested overlay is silently ignored by confuse.

* fix(onlinetag): resolve prompts against current DB path, surface apply failures

Answering a deferred prompt fetched the chosen issue against the path
serialized into the prompt at scan time. When an earlier write for the
same comic ran with rename enabled (the comic's other source's prompt,
or a stored-id prefetch), that path was stale and the apply died with an
uncaught FileNotFoundError — after the prompt was already consumed, so
the admin's pick vanished with no feedback.

- _apply_resolution now re-reads the comic's path from the DB by pk;
  a missing row reports to the Tagging error panel instead of fetching
  a dead path.
- fetch/replay failures (ComicboxError, OSError) and non-resolving
  explicit ids now land on the Tagging error panel instead of only the
  log, since the pick can no longer be re-prompted.
- stored-id prefetch and tag_by_id also catch OSError so a vanished
  file degrades gracefully.
- regression test for the COMICBOX_CONFIG general-section overlay
  (un-nested loglevel/delete_keys were silently ignored, letting
  comicfn2dict remainders like "(0000)" leak into rename targets).

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

* feat(auth): move OIDC config from codex.toml to the Admin UI Auth tab

OIDCSettings DB singleton (EmailSettings pattern) becomes the sole
config source, read at request time:

- OIDCSettings model + migration 0047 (seeds pk=1, one-time courtesy
  import of any pre-GUI [auth.oidc] TOML values); client_secret
  encrypted at rest via EncryptedCharField
- get_oidc_settings()/oidc_enabled() in settings.db; cachalot makes
  admin edits live on the next request, no restart
- codex/oidc.py rewired to request-time reads; new adapter
  list_apps override builds an unsaved SocialApp from the row
  (per-app settings['scope'] wins in allauth's get_scope), so
  disabled state keeps allauth's own DoesNotExist -> 404 gating
- RP-initiated logout and session flags read the row
- AdminOIDCSettingsView GET/PUT (write-only secret + clientSecretSet
  mirror, discovery-cache invalidation on save) and AdminOIDCTestView
  (discovery-document probe) at /api/v4/admin/oidc-settings[/test]
- New Admin UI Auth tab mirroring the Email tab: draft/dirty
  tracking, never-echoed secret with Clear Credential, redirect-URI
  display, Test Connection endpoint report
- [auth.oidc] TOML section and CODEX_AUTH_OIDC_* env overrides
  removed; README updated
- Tests now seed the DB row instead of patching module constants

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

* format

* update deps and fix

* fix(admin): gate the OIDC enable switch on server URL + client ID

The Auth tab's Enable OIDC Login checkbox is disabled until a valid
server URL and a client ID are entered (it can always be unchecked so
clearing a field never strands the switch). The serializer enforces
the same invariant for API clients and partial updates that blank a
prerequisite while enabled — previously such a save produced a
silently inert enabled=true row.

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

* feat(admin): Auth tab gains Account & Access flags, tinyauth note, name gate

- Move the Account & Access flag cards (Registration, Verify New User
  Email, Non-Users) from the Users tab to the Auth tab — they govern
  how people get in, which is that tab's subject
- Auth tab prose explains that forward-auth gateways like tinyauth are
  not OIDC providers and points them at Remote-User header auth, which
  coexists with OIDC
- Provider name joins server URL and client ID as an enable
  prerequisite, in the UI switch gate and the serializer invariant

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

* style(admin): visually nest the OIDC subsections under their header

AdminSection gains a sub variant: a small uppercase overline title (h4,
$text-meta) and an indented left rule, with tighter sibling rhythm than
top-level sections. The Auth tab wraps the whole OIDC block — prose,
Identity Provider, User Mapping, Logout, and Test Connection — in one
parent 'OIDC Single Sign-On' AdminSection with the config groups as sub
sections, so their subordination to the OIDC header is unmistakable
next to the sibling Account & Access section. Documented in DESIGN.md.

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

* style(admin): collapse the OIDC section when OIDC is disabled

Most admins never configure OIDC, so the section body — prose, config
sub-sections, and Test Connection — hides behind an AdminExpandToggle
disclosure. It starts expanded only when OIDC is already enabled;
otherwise a one-line hint summarizes what's inside next to a Configure
toggle. The disclosure is initialized once from the saved state so
saving a disable doesn't slam the panel shut mid-edit.

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

* docs(admin): plain-English hints for PKCE and other OIDC jargon fields

PKCE, Client ID, Username Claim, and Groups Claim now carry hints an
admin who has never touched OIDC can act on — including what a claim
is and why PKCE should stay on.

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

* test(sso): authentik + tinyauth manual test harness in test-proxy/

Adds a docker-compose IdP stack and nginx wiring so SSO can be manually
verified before release:

- compose.yaml: authentik (OIDC provider, :9010) + tinyauth (forward
  auth, :3232), everything bound to localhost with throwaway creds
- authentik/blueprints/codex-test.yaml: auto-applied fixtures — readers
  and codex-admins groups, testuser/testadmin, and the codex-test OIDC
  client with callback URIs for proxied and direct, prefixed and bare
- forwardauth.conf: nginx :8081 gating Codex behind tinyauth
  auth_request with an overriding Remote-User header
- README.md: step-by-step test matrix covering native OIDC (login,
  group sync, admin mapping, RP logout, linking, error page, disabled
  404) and forward-auth (login, gating, spoof-proofing, coexistence)

tinyauth DB path pinned to the writable /data volume (workdir is
root-owned). test-proxy/ excluded from eslint: authentik !Find tags and
compose healthcheck arrays require flow-style YAML the yml plugin bans.

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

* test(sso): run the harness nginx as a compose service

nginx joins authentik + tinyauth in compose.yaml, so only Codex runs on
the host. The native bin/run-test-proxy.sh path still works — both share
server.conf/forwardauth.conf, with the sole native-vs-container
difference (backend addresses) isolated into named upstreams:

- upstreams-native.conf: localhost backends (host nginx)
- upstreams-docker.conf: host.docker.internal + tinyauth service name
- connection-upgrade.conf: the ws-upgrade map, now shared
- ssl-listen.conf / ssl-listen-none.conf: SSL/QUIC listeners split out so
  the container serves plain HTTP (native keeps the 8443 listeners)

The compose nginx mounts these into the stock image's conf.d and reaches
host Codex via host.docker.internal (extra_hosts host-gateway for Linux).

Also fixes a latent harness bug that would break OIDC through the proxy:
X-Forwarded-Host used $host (strips the port), so Django's
build_absolute_uri produced a portless redirect_uri that couldn't match
the registered callback. Now $http_host, port included.

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

* test(sso): fix tinyauth boot crash on localhost app URL

tinyauth v5 derives a cookie domain from its app URL at startup and
rejects single-label hosts and IPs ('invalid app url, must be at least
second level domain'), so http://localhost:3232 crash-looped. Move the
forward-auth path onto *.localtest.me (all subdomains resolve to
127.0.0.1 via public DNS, every browser, no /etc/hosts):

- tinyauth app url -> http://tinyauth.localtest.me:3232
- gated Codex      -> http://codex.localtest.me:8081
- shared cookie    -> .localtest.me (spans both)

The @tinyauth_login redirect and README Test 2 follow. OIDC/authentik
stay on localhost (no cross-host cookie needed there).

Also documents in README Troubleshooting that the harness publishes only
9010/8080/8081/3232 and never binds Vite's 5173 — a blocked HMR is a
stale vite process, and 8080/8081 clashes come from running native
make dev-reverse-proxy alongside the compose nginx.

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

* test(sso): set authentik provider grant_types; drop IPv6 host-gateway

Two issues from the first live OIDC run:

- 'Login with Authentik' failed with authentik logging 'Invalid
  grant_type for provider'. authentik 2026.x added an explicit
  grant_types model field that defaults to an EMPTY list, so a blueprint
  that omits it creates a provider allowing no grants and the authorize
  step returns invalid_request. Set grant_types: [authorization_code,
  refresh_token] on the provider.

- nginx logged 'connect() to [fd..::254]:9810 Network unreachable' then
  fell back to IPv4. The IPv6 came from extra_hosts host-gateway (a
  Docker Desktop IPv6 ULA gateway Granian doesn't listen on). Comment it
  out — Docker Desktop provides an IPv4 host.docker.internal built-in;
  Linux users uncomment it.

README troubleshooting covers both, including re-applying the blueprint
to an already-running authentik.

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

* fix(auth): refresh public flags when OIDC is toggled or on logout

The 'Login with <provider>' button (adminFlags.oidcEnabled) went stale
after disabling OIDC: OIDCSettings is a singleton with no
admin.flags.changed websocket broadcast, and logout() left adminFlags
untouched, so the button lingered on the login screen until a manual
page reload.

- admin.updateOidcSettings now calls auth.loadAdminFlags after a save,
  resyncing the public OIDC flags immediately.
- auth.logout now re-fetches public flags (except when doing an
  RP-initiated full-page redirect, which reloads anyway), so the
  logged-out login screen always reflects current settings.

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

* update deps

* chore(lint): clear radon CC/MI and remark warnings

- codex/oidc.py: extract CodexSocialAccountAdapter._sync_admin from
  _sync_user (rank C -> B); keyword-only bool arg for FBT001.
- tests: split the 940-line test_onlinetag_session_manager (MI rank B,
  pre-existing on develop) — move the TagPassRunner and stored-id-map
  classes into test_onlinetag_tag_pass.py, importing the shared doubles
  from the session-manager module (as test_opds_schema already does).
  Both files now MI rank A.
- test-proxy/README.md: wrap the bare http://localhost autolink in <>
  and the [fd..::254] nginx error in backticks so remark-lint stops
  reading it as a link reference.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simyan 3.0 removed the cache= constructor kwarg. The credential check
now passes cache_expiry=DO_NOT_CACHE with the cache/ratelimit sqlite
files in a throwaway temp dir, so validation always hits the network
(api_key is excluded from simyan's cache key) and leaves no files
behind. Also note comicbox 4.1.1's ComicVine improvements in the
v2.2.0 news.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ajslater and others added 29 commits August 21, 2026 19:35
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>
…cade (#825)

* fix(watcher): re-read a file replaced in place instead of deleting it

A tool that swaps a file by ``rm`` + ``mv``, and any watcher backend that
reports an atomic replace as a delete plus an add, leaves both events in
one batch. The recreated file carries a new inode, so move detection can
never pair them, and dedup let the delete win: the row died, cascading
its bookmarks and read progress, while a file sat at that very path. The
comic then reappeared on the next scan as a new, unread one.

It is the same path with new content, which is a modification. The
poller already reached that conclusion by diffing snapshots; this makes
the watcher agree. Custom covers are treated the same way.

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

* fix(importer): never delete a comic whose file is still on disk

Deleting a comic row cascades its bookmarks and read progress away, and
nothing brings them back — the next scan re-imports the file as a fresh,
unread comic. Yet both scanners *infer* deletes, and every inference has
failure modes that name a path still sitting on disk: a watch batch that
carries a delete whose paired add lands in the next batch, a directory
expansion that overmatched, an inode pair the compatibility checks
refused.

So the delete phase now confirms each path against the filesystem and
leaves anything still there for the next scan to reconcile. A stale row
costs a re-read; a wrongly deleted one costs the user their place in the
book. Comics, folders and custom covers all check.

This cannot save a library whose whole mount vanished, where every path
reads as missing, so a delete large enough to look like that logs where
to go looking instead.

Also fixes a browser staleness bug in the same phase: comics under a
deleted folder die by cascade rather than by path, so they never reached
the collection capture, and the series or publisher a folder delete
emptied was never re-stamped — browsers kept listing comics that were
gone. They were also counted as folders rather than comics.

The move-guard test that asserted an unrelated comic still deletes left
its file on disk, so its fixture now removes it.

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

* docs(news): delete backstop and replaced-file fixes in v2.2.11

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

---------

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

* feat(tagging): rename before writing, and sync the database inline

Renaming used to happen after the write, and the database was told about
it afterwards by an ``ImportTask`` queued behind whatever else the scribe
was doing. Between those two moments the row pointed at a path that no
longer existed, and any scan processed in the gap saw an unexplained
delete plus create: it deleted the row and cascaded the comic's bookmarks
and read progress away. Three rounds of fixes narrowed that gap without
closing it, because the gap was the design.

So the order is inverted. Each archive is renamed to the name comicbox
predicts for it — with the pending patch overlaid, so the name reflects
the tags about to be written — and every resulting move is applied to the
database before ``write_tags`` returns, on the scribe's own thread. No
scan can be processed while the two disagree. A stale delete then finds
no row, and a stale create converges onto the row already at that path.

A conversion still moves the file after the write, so it is synced the
same way the moment the batch finishes. What stays queued is only the
metadata re-read, which is safe precisely because it names a path the
database already holds.

Consequences:

- Renaming is planned for the whole batch first, so two comics predicting
  one name, or a name already taken on disk or in the database, are
  reported instead of colliding. Both the interim and the post-conversion
  destination are checked, since comicbox refuses to convert onto an
  existing file and that refusal would land after the rename.
- A move the database refuses puts the file back, so disk and database
  cannot diverge.
- A second edit of the same comics now resolves the renamed paths, where
  before every write in it failed with "no such file".
- Whether a library is watched no longer changes any of this.

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

* docs(news): rename-first tag writes in v2.2.11

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

* refactor(importer): drop the tag-write move deferral

The other half of the guard: the importer no longer holds back paths an
in-flight tag write is moving through, because there is no longer a
window in which to hold them. Moves are applied before ``write_tags``
returns, so any scan the importer processes is reconciling against a
database that already agrees with the disk.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(watcher): don't delete a library that is only unmounted

A dropped network share, an ejected volume, or a docker bind mount that
didn't come up presents as an empty or missing directory rather than an
error, so every comic in the library looks deleted at once. Acting on
that removes every row and cascades away every bookmark and reading
position in the library — for files that are perfectly fine and will be
back as soon as the mount is.

The poller has refused to scan a library in that state for a long time,
by three separate checks. The watcher had none: it already holds the
events, so it deleted. The delete phase's existence check can't help
either, because while the mount is gone the files genuinely are
unreachable.

The watcher now consults the same checks before acting on any task that
carries deletes. Adds and modifies are left alone; they can't destroy
anything. Those checks move to ``codex.librarian.fs.mounted`` so both
scanners share one definition of what a vanished library looks like,
rather than one of them growing a defense the other never hears about.

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

* docs(news): unmounted library guard in v2.2.11

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The rename work landed in stages, and each stage added its own entry, so
three of them described the same guarantee from different distances:
comics keeping their bookmarks through a rename. They are now one item
that describes the behavior that actually shipped rather than the route
taken to it. The two path-prefix fixes, which are the same mistake in two
places, become one; so do the two ways codex could delete a comic whose
file was still there.

Two user-visible fixes were missing entirely: the online-tag Resume
button no longer offers itself when it cannot work (and reports a failed
pause or resume instead of silently doing nothing), and two comics that
would take the same filename no longer overwrite each other.

Thirteen items to eleven, each shorter.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@ajslater
ajslater merged commit cec78b6 into main Aug 26, 2026
4 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