3 Commits
Author SHA1 Message Date
John O'Keefe 91b0c45dd4 feat(scanner): extractor-version (ev) mechanism — parser fixes reach old rows without a force rescan (faceted plan Phase 10)
Parser fixes (7e4c97f6, genre model v2) never reached rows ingested
under an older parser: the sidecar never changed, so every fingerprint
said 'unchanged'. The manifest JSONB now carries the reserved 'ev'
stamp — the parser version that produced the item's row.

Semantics (all pinned by tests):
- ABSENT ev decodes as 1 ('v1-or-unknown'); stamping is truthful-only:
  create paths + post-extraction stores stamp the setting's current
  value; deploy-day backfill and the extract-failure guard stay UNSET
  (a row never claims a version its data can't prove — a mid-backfill
  bump self-heals next poll); repoint reconciliation preserves the
  stored ev (stat-only refresh re-extracted nothing).
- NEW setting scan_extractor_version (seeded + registry + getter,
  int default 1, category scanner): a manifest whose ev != the setting
  re-extracts through the SAME change path (full extraction →
  updateMediaItemWithMetadata → fresh stamped manifest). Sidecar-less
  books get an entries-less manifest as their ev home (create: stamped;
  first-sighting: unset).
- Catch-up bounded: evReextractBudgetPerScan=100, atomic (watcher pass
  can overlap a poll scan), reset per ScanFolders — amortized +
  resumable across polls; no force rescan, no flood.
- Multifile books included: the ev gate rides multifileSidecarChanged's
  shared ladder; ev-driven manifestChanged drives the editorial apply
  + stamped re-store.

Settings API: the unified GET/PUT /api/system/settings now lists the
key (registry-driven, auto-surfaces in the admin UI). Test harness:
the registry is now Loaded + wired into the SystemSettingsHandler
(main.go parity) — settings writes through the API were 503s before.

Verified: unit (ev normalization, budget CAS, GetExtractorVersion
cache paths, registry clamping); integration state machine —
create-stamp ev=1 → equality skips (corruption probe persists) →
wiped scan-state backfills UNSET without re-extract → bump re-extracts
ONCE (sidecar value restored, ev=2) → stable (second corruption
persists); multifile twin green; adjacent suites (Audiobook, Calibre,
CrossLibraryMove, SchemaIdempotency) green; live app restart re-scans
1198 files with 0 changed (deploy-day rule), setting listed at
/api/system/settings.
2026-10-20 18:24:08 -04:00
John O'Keefe 2c5ad014c0 feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6)
Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.

- Change fingerprint gains the manifest compare in both processors:
  changed := size differs || track payload differs || manifest drifted.
  The ladder runs against the bulk-loaded map entry (stat pairs only,
  zero extra queries); NULL stored manifest backfills without
  re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
  editorial columns (updateMediaItemWithMetadata, overrides protected)
  and re-runs the playlist hook. The apply runs BEFORE the focused
  payload write: the generic update's format-group pass derives mime
  from the sentinel (none) and chapters from metadata.ChapterCount
  (unset) — the payload write must win (rescanMultiFileAudioBook's
  ordering). Track-only changes keep the existing editorial-keep
  update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
  sentinel — the exact representative the extractor reads — via
  detectSidecarChangeWithRepresentative, so manifest and extractor can
  never disagree; a directory-basename cover is ignored by the
  extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
  folder path always did (a returned zip book stayed archived
  forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
  non-nil empty &MediaMetadata{} fallback bypassed the update path's
  filename-title guard (preExtracted == nil only) and would blank the
  title on a failed extraction; the row is now left untouched and the
  manifest refreshed. Empty-success extraction (sidecar removed from a
  tag-less book) now passes nil so the create-equivalent chain (tags →
  filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
  they were previously uncounted.

Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
2026-10-19 22:33:01 -04:00
John O'Keefe 6750e9e0dc feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.

- Sidecar manifest fingerprints (media_scanner_manifest.go): the
  manifest stats exactly the names the extractors consume (metadata.opf,
  metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
  manifest and extractors can never disagree. Shape v1:
  {v, entries:[{n, s, m (unix nanos — second granularity would hide
  edits landing in the backfill scan's own second), h (sha256 — covers
  included: the repoint ladder needs a baseline hash for any stat-
  drifted sidecar; a hashless cover turns harmless mtime drift into a
  re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
  ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
  reconciliation, stat pair refreshed, no re-extraction; differing =
  changed); NULL stored manifest → backfill only, NO re-extraction
  (deploy-day rule: the first scan after release must not look like an
  mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
  through updateMediaItemWithMetadata (preExtracted — metadata_overrides
  stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
  a reading-order entry added to an EXISTING book now creates its
  collection; add-if-missing is idempotent), store a fresh full
  manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
  change path does not consume sidecar extractors, so gating them would
  flip books 'changed' without picking anything up (plan correction —
  the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.

Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
2026-10-19 20:17:57 -04:00