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.
This commit is contained in:
John O'Keefe committed 2026-10-19 22:33:01 -04:00
1 parent 5245f292ca
commit 2c5ad014c0
5 files changed
+444 -17

No files matched your search

@@ -24,6 +24,46 @@ func TestDecodeSidecarManifest(t *testing.T) {
}
}
// TestStatSidecarManifestSentinelNames pins the Phase 6 name derivation
// for folder books: the manifest must stat exactly the names the
// extractor consumes through the sentinel (metadata sidecars + the
// standard cover set) — never the directory's own basename (the
// extractor's basename-pattern covers resolve against the sentinel's
// basename, which generates no candidates).
func TestStatSidecarManifestSentinelNames(t *testing.T) {
dir := t.TempDir()
write := func(name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
write("metadata.json")
write("cover.jpg")
write(filepath.Base(dir) + ".jpg") // extractor never reads this
sentinel := filepath.Join(dir, ".audiobook-root")
m := statSidecarManifest(sentinel, dir)
if m == nil {
t.Fatal("manifest must stat, not nil")
}
seen := map[string]bool{}
for _, e := range m.Entries {
seen[e.Name] = true
}
if !seen["metadata.json"] || !seen["cover.jpg"] {
t.Errorf("extractor-consumed sidecars missing from manifest: %v", seen)
}
if seen[filepath.Base(dir)+".jpg"] {
t.Errorf("directory-basename cover must not be tracked (extractor ignores it): %v", seen)
}
for name := range seen {
if name == ".audiobook-root.jpg" {
t.Errorf("sentinel-basename candidates must not appear (they cannot exist): %v", seen)
}
}
}
func TestReconcileSidecarManifest(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {