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.
This commit is contained in:
John O'Keefe committed 2026-10-19 20:17:57 -04:00
1 parent 693053a9f5
commit 6750e9e0dc
9 files changed
+633 -14

No files matched your search

+14
View File
@@ -452,6 +452,20 @@ FROM media_items mi
LEFT JOIN media_scan_state s ON s.media_item_id = mi.id
WHERE mi.library_id = sqlc.narg('library_id');
-- UpsertMediaScanState stores a book's sidecar manifest fingerprint
-- (faceted plan Part 2 §3). Written on backfill (first sighting —
-- no re-extraction), on repoint reconciliation (stat pairs refreshed,
-- content identical), and after a manifest-gated re-extract.
-- name: UpsertMediaScanState :exec
INSERT INTO media_scan_state (media_item_id, manifest)
VALUES ($1, $2)
ON CONFLICT (media_item_id) DO UPDATE SET manifest = EXCLUDED.manifest;
-- GetMediaScanState reads one item's manifest (per-item rescan paths
-- and the manifest integration tests).
-- name: GetMediaScanState :one
SELECT media_item_id, manifest FROM media_scan_state WHERE media_item_id = $1;
-- name: GetMediaItemByFilePathAnyLibrary :one
SELECT * FROM media_items WHERE file_path = $1 LIMIT 1;