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

+29 -6
View File
@@ -1498,12 +1498,36 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
if err != nil {
return false, fmt.Errorf("sidecar change re-read failed: %v", err)
}
// Extract once and pass it in, so the playlist sync below sees
// the sidecar's ordering entries (metadata_overrides stay
// protected inside the update path either way).
// Extract once; the result also carries the sidecar's playlist
// entries for the sync below.
metadata, extractErr := s.extractMetadata(path)
dir := bookSidecarDir(path)
if extractErr != nil || metadata == nil {
metadata = &MediaMetadata{}
// Extract-failure guard (faceted plan Phase 6 audit find):
// UpdateMediaItem writes editorial columns unconditionally,
// and preExtracted != nil disables the update path's
// filename-title guard — a non-nil empty fallback would blank
// the row. Keep the row untouched and refresh the manifest so
// the failure isn't re-detected every scan; Phase 9's ledger
// records the processing issue. No extractor error path is
// reachable today (mergeMetadata never fails) — this is the
// regression net for the class of bug the guard exists for.
fmt.Printf("Warning: sidecar re-extraction failed for %s: %v — row left unchanged\n", path, extractErr)
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
return false, nil
}
// Playlist entries ride the FIRST extraction either way — capture
// them before the reroute below.
playlistSeries := metadata.PlaylistSeries
if metadata.Title == "" {
// Empty-success extraction (a tag-less book whose sidecar
// vanished): preExtracted != nil would skip the update path's
// filename-title guard AND the audio tag re-derivation —
// blanking the title. Passing nil delegates to the function's
// own create-equivalent chain instead (sidecars → folder
// structure → tags → filename), mirroring the create path's
// last rung.
metadata = nil
}
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
@@ -1511,10 +1535,9 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
// Playlist sync on the change path (plan Correction 2): a
// "(Reading Order)" entry added to an EXISTING book's sidecar
// still creates its collection — add-if-missing is idempotent.
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, metadata.PlaylistSeries)
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, playlistSeries)
// Fresh full manifest: the next scan compares against TODAY's
// sidecars, not the stale pre-edit fingerprint.
dir := bookSidecarDir(path)
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
s.changedItems++
return false, nil
@@ -840,7 +840,25 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
}
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID)
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) {
// The representative drives the sidecar/folder-structure checks; they
// all look at Dir(representative), so a sentinel INSIDE the root
// resolves covers and metadata.json living there (including disc-
// layout roots where track zero sits deeper).
sentinel := filepath.Join(rootDir, ".audiobook-root")
// Manifest-gated sidecar detection (faceted plan Phase 6): a
// size-stable folder book is changed by EDITING its sidecars —
// including on scans where the track fingerprint says "unchanged".
// The ladder is stat-only in the steady state; a detected edit falls
// through to the full probe below so the once-extracted metadata can
// feed the editorial apply.
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, rootDir, sentinel, rootDir)
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) && !manifestChanged {
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
@@ -888,11 +906,9 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
}
orderTracksByTags(probed)
// The representative path drives the sidecar/folder-structure checks;
// they all look at Dir(representative), so a sentinel INSIDE the root
// resolves covers and metadata.json living there (including disc-
// layout roots where track zero sits deeper).
sentinel := filepath.Join(rootDir, ".audiobook-root")
// The sentinel is defined above, next to the manifest check that
// shares it; the probe pass reuses it as the extraction
// representative.
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
metadata.AudioMeta = payload
@@ -915,10 +931,34 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
switch {
case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
if changed || s.forceRescan {
if changed || s.forceRescan || manifestChanged {
// Editorial apply BEFORE the payload write (rescanMultiFileAudioBook's
// ordering): the generic update's format-group pass derives the
// mime from the sentinel (none) and chapters from
// metadata.ChapterCount (unset) — the focused payload write below
// must win. Force rescan keeps the editorial-keep contract (the
// per-book RescanMediaItem is the editorial tool), so it skips
// the apply even when the ladder flagged a drift it caused.
if manifestChanged && !s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
}
// Fresh full manifest: the next scan compares against
// TODAY's sidecars, not the stale pre-edit fingerprint.
s.storeMultiFileManifest(ctx, libraryID, rootDir, sentinel, rootDir)
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
// The book is demonstrably back and fresh: lift any archive
// state the missing-root scans set (the same lift the
// unchanged path applies).
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
}
}
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Unchanged content that was archived while the root was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
@@ -936,6 +976,10 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
// Seed the sidecar manifest at create (Phase 6): names derive
// from the sentinel — the exact representative the extractor
// reads — so the manifest can never disagree with extraction.
s.seedMultiFileManifest(ctx, libraryID, created.ID, rootRel, sentinel, rootDir, totalSize)
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
s.newItems++
default:
@@ -964,6 +1008,63 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
return nil
}
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
// (faceted plan Phase 6): a size-stable multi-file book is changed by
// EDITING its sidecars — the track-count/size fingerprint never saw it,
// not even on force rescan. The bulk-map entry is keyed by the item's
// file_path, but sidecar NAMES derive from the representative the
// extractor reads (the sentinel inside a folder root, the zip itself).
// NULL stored manifest → backfill only (deploy-day rule); no bulk entry
// (nil map, brand-new item) → not detected this pass.
func (s *MediaScanner) multifileSidecarChanged(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) bool {
entry, ok := s.scanStateLookup(libraryID, itemPath)
if !ok {
return false
}
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, representative, dir)
}
// applyMultiFileSidecarEdit lands the once-extracted book metadata
// (sidecars → folder structure → first-track tags → fallback title) on
// the editorial columns of an EXISTING folder/zip book — exactly what
// rescanMultiFileAudioBook applies; metadata_overrides stay protected
// inside the update. The caller runs the focused payload write AFTER
// this: the generic update's format-group pass derives the mime type
// from the sentinel/zip path (none/audio-container) and the chapter
// count from metadata.ChapterCount (unset on the book chain), so the
// payload write must win. The playlist hook re-runs because a
// "(Reading Order)" entry added to an EXISTING book's sidecar still
// creates its collection (add-if-missing is idempotent).
func (s *MediaScanner) applyMultiFileSidecarEdit(ctx context.Context, existing database.MediaItems, representative string, metadata *MediaMetadata) error {
if err := s.updateMediaItemWithMetadata(ctx, existing, representative, metadata); err != nil {
return fmt.Errorf("failed to apply sidecar metadata to %s: %w", existing.FilePath, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, existing.ID, metadata.PlaylistSeries)
fmt.Printf("[AUDIO] Sidecar metadata applied to '%s'\n", existing.Title)
return nil
}
// seedMultiFileManifest inserts the new book's scan-state entry and
// hashes its sidecars into the initial manifest — the multifile twin of
// the single-file create tail: the next scan compares instead of
// backfilling, so an edit is detected on its first post-seed scan.
func (s *MediaScanner) seedMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemID pgtype.UUID, relPath, representative, dir string, totalSize int64) {
newEntry := &scanEntry{ID: itemID, FilePath: relPath, FileSize: totalSize}
s.scanStateUpsert(libraryID, newEntry)
if computed := statSidecarManifest(representative, dir); len(computed.Entries) > 0 {
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(representative, dir, computed))
}
}
// storeMultiFileManifest refreshes the book's full manifest after a
// change-path apply: the next scan compares against TODAY's sidecars,
// not the stale pre-edit fingerprint.
func (s *MediaScanner) storeMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) {
if entry, ok := s.scanStateLookup(libraryID, itemPath); ok {
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, statSidecarManifest(representative, dir)))
}
}
// processZipAudioBook ingests a zip archive of audio tracks as ONE book.
// The zip is the item's file (downloadable like any single file);
// audio_metadata carries layout "zip" and per-entry track paths the
@@ -990,7 +1091,26 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
}
existing, existingErr := s.getMediaItemByFilePath(ctx, zipRel, libraryID)
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) {
// Manifest-gated sidecar detection (faceted plan Phase 6) — the zip
// itself is both the map key and the representative: its sidecars are
// the siblings the extractor reads (metadata.json next to the zip,
// "<zip-basename>.<ext>" covers).
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) && !manifestChanged {
// Archive-restore parity with the folder path (Phase 6 fix): a
// zip book whose file returned must not stay archived forever.
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
return nil
}
@@ -1045,10 +1165,32 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
switch {
case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
if changed || s.forceRescan {
if changed || s.forceRescan || manifestChanged {
// Editorial apply BEFORE the payload write — see the folder
// processor for the format-group ordering rationale.
if manifestChanged && !s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
}
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
}
}
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Parity with the folder change branch: content unchanged but
// the row was archived while the zip was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
case errors.Is(err, pgx.ErrNoRows):
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime)
@@ -1059,6 +1201,7 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
s.seedMultiFileManifest(ctx, libraryID, created.ID, zipRel, zipPath, filepath.Dir(zipPath), totalSize)
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
s.newItems++
default:
+13 -2
View File
@@ -228,7 +228,18 @@ func (s *MediaScanner) storeManifest(ctx context.Context, libraryID pgtype.UUID,
// manifest after a change-path update.
func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool {
dir := bookSidecarDir(path)
computed := statSidecarManifest(path, dir)
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, path, dir)
}
// detectSidecarChangeWithRepresentative is detectSidecarChange with the
// sidecar-name derivation decoupled from the map key. Multi-file books
// (faceted plan Phase 6) are keyed by the item's file_path (the folder
// root / zip), but the extractor reads a folder book's sidecars through
// the sentinel INSIDE the root — whose basename yields no "<base>.<ext>"
// cover candidates — so the manifest must stat the representative's name
// list to never disagree with what extraction consumes.
func (s *MediaScanner) detectSidecarChangeWithRepresentative(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, representative, dir string) bool {
computed := statSidecarManifest(representative, dir)
stored := decodeSidecarManifest(entry.Manifest)
if len(computed.Entries) == 0 {
@@ -240,7 +251,7 @@ func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry
if stored == nil {
// Deploy-day backfill: hash + store WITHOUT re-extraction.
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, computed))
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, computed))
return false
}
@@ -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) {