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

@@ -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: