From 2c5ad014c009487464f0ea95c4fa24047d51e99f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 19 Oct 2026 22:32:52 -0400 Subject: [PATCH] feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/audiobook_multifile_test.go | 210 ++++++++++++++++++ internal/services/media_scanner.go | 35 ++- .../services/media_scanner_audio_multifile.go | 161 +++++++++++++- internal/services/media_scanner_manifest.go | 15 +- .../services/media_scanner_manifest_test.go | 40 ++++ 5 files changed, 444 insertions(+), 17 deletions(-) diff --git a/cmd/server/tests/audiobook_multifile_test.go b/cmd/server/tests/audiobook_multifile_test.go index 27d29039..3d08bb30 100644 --- a/cmd/server/tests/audiobook_multifile_test.go +++ b/cmd/server/tests/audiobook_multifile_test.go @@ -569,3 +569,213 @@ func TestRescanKeepsFilenameTitle(t *testing.T) { require.NoError(t, err) require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title") } + +// TestAudiobookFolderSidecarEditDetected is the Phase 6 headline: a +// size-stable folder book is changed by EDITING its metadata.json — +// the track-count/size fingerprint never saw it, not even on force +// rescan. Pins the ladder end to end: seeding at create, pure-skip +// steady state, same-size sidecar edit consumed (title + collection +// sync), and the refreshed manifest. +func TestAudiobookFolderSidecarEditDetected(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Sidecar %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + bookDir := filepath.Join(folder, "Sidecar Author", "Sidecar Dir Name") + writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3")) + writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3")) + sidecar := filepath.Join(bookDir, "metadata.json") + // Sidecar title deliberately differs from the directory name so the + // assertion proves the SIDECAR won. + require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Title"}`), 0o644)) + + scanTestFolder(t, setup, admin, folder) + + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Author/Sidecar Dir Name")) + require.True(t, found) + require.Equal(t, "Sidecar Real Title", book.Title, "sidecar title wins at create") + require.NotEqual(t, book.UpdatedAt.Time, time.Time{}, "sanity") + + // Manifest seeded at create (the multifile create tail). + state, err := setup.DB.GetMediaScanState(context.Background(), book.ID) + require.NoError(t, err) + require.NotEmpty(t, state.Manifest, "folder create must seed the sidecar manifest") + seededManifest := state.Manifest + + // Steady state: the manifest ladder runs and skips — pure skip. + scanTestFolder(t, setup, admin, folder) + afterSkip := findItemUpdated(t, setup, libID, book.FilePath) + require.Equal(t, book.UpdatedAt.Time, afterSkip, "unchanged rescan with seeded manifest is a pure skip") + + // Same-size sidecar edit — the size-only blind spot. + sameSize := []byte(`{"title":"Sidecar Real Titlz"}`) + require.Len(t, sameSize, len(`{"title":"Sidecar Real Title"}`), "fixture must be a same-size edit") + require.NoError(t, os.WriteFile(sidecar, sameSize, 0o644)) + scanTestFolder(t, setup, admin, folder) + + edited := findAudioItemByPathRow(t, setup, libID, book.FilePath) + require.Equal(t, "Sidecar Real Titlz", edited.Title, "same-size sidecar edit is detected and consumed") + + state, err = setup.DB.GetMediaScanState(context.Background(), edited.ID) + require.NoError(t, err) + require.NotEmpty(t, state.Manifest) + require.NotEqual(t, string(seededManifest), string(state.Manifest), "change path stores a fresh full manifest") + + // Playlist sync on the change path (plan Correction 2): a reading- + // order entry added to an EXISTING book creates its collection, and + // a further rescan does not duplicate it. + require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644)) + scanTestFolder(t, setup, admin, folder) + var collCount int + require.NoError(t, setup.DBPool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin, + ).Scan(&collCount)) + require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book") + scanTestFolder(t, setup, admin, folder) + require.NoError(t, setup.DBPool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin, + ).Scan(&collCount)) + require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection") +} + +// TestAudiobookTrackOnlyChangeKeepsEditorial pins the other half of the +// contract: a TRACK change (sidecars untouched) keeps the existing +// scan-derived-only update — editorial columns stay as the sidecar +// wrote them, and no editorial apply fires. +func TestAudiobookTrackOnlyChangeKeepsEditorial(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio TrackOnly %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + bookDir := filepath.Join(folder, "Track Only Author", "Track Only Book") + writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3")) + writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3")) + require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.json"), []byte(`{"title":"Editorial Anchor"}`), 0o644)) + + scanTestFolder(t, setup, admin, folder) + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Track Only Author/Track Only Book")) + require.True(t, found) + require.Equal(t, "Editorial Anchor", book.Title) + + // A third track arrives; sidecars untouched → track-count change → + // payload update only. + writeTestTrack(t, filepath.Join(bookDir, "03 third.mp3")) + scanTestFolder(t, setup, admin, folder) + + updated := findAudioItemByPathRow(t, setup, libID, book.FilePath) + layout, tracks, _, _ := decodeAudioPayload(t, updated) + require.Equal(t, "folder", layout) + require.Equal(t, 3, tracks, "track change takes the payload update") + require.Equal(t, "Editorial Anchor", updated.Title, "track-only change must not touch editorial columns") +} + +// TestAudiobookZipSidecarEditAndRestore covers the zip twin: sibling +// metadata.json edits are detected, and the fast path lifts archive +// state like the folder path always did (Phase 6 parity fix). +func TestAudiobookZipSidecarEditAndRestore(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio ZipSidecar %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + dir := t.TempDir() + writeTestTrack(t, filepath.Join(dir, "a.mp3")) + writeTestTrack(t, filepath.Join(dir, "b.mp3")) + zipPath := filepath.Join(folder, "Zip Sidecar Book.zip") + zipFiles(t, zipPath, map[string]string{ + "01 first.mp3": filepath.Join(dir, "a.mp3"), + "02 second.mp3": filepath.Join(dir, "b.mp3"), + }) + sidecar := filepath.Join(folder, "metadata.json") + require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Title"}`), 0o644)) + + scanTestFolder(t, setup, admin, folder) + + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zip Sidecar Book.zip")) + require.True(t, found) + require.Equal(t, "Zip Sidecar Title", book.Title, "sibling sidecar feeds the zip book at create") + + state, err := setup.DB.GetMediaScanState(context.Background(), book.ID) + require.NoError(t, err) + require.NotEmpty(t, state.Manifest, "zip create must seed the sidecar manifest") + + scanTestFolder(t, setup, admin, folder) + require.Equal(t, book.UpdatedAt.Time, findItemUpdated(t, setup, libID, book.FilePath), "unchanged zip rescan is a pure skip") + + require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Titlz"}`), 0o644)) + scanTestFolder(t, setup, admin, folder) + edited := findAudioItemByPathRow(t, setup, libID, book.FilePath) + require.Equal(t, "Zip Sidecar Titlz", edited.Title, "zip sidecar edit is detected and consumed") + + // Archive the row the way the missing-scan does; the file is back, + // so the next scan must lift the archive (parity with folders). + require.NoError(t, setup.DBPool.QueryRow(context.Background(), + `UPDATE media_items SET archived_at = NOW() WHERE id = $1 RETURNING id`, edited.ID, + ).Scan(new(pgtype.UUID))) + scanTestFolder(t, setup, admin, folder) + + restored := findAudioItemByPathRow(t, setup, libID, book.FilePath) + require.False(t, restored.ArchivedAt.Valid, "zip fast path must lift archive state when the file is back") + require.Equal(t, "Zip Sidecar Titlz", restored.Title) +} + +// TestAudiobookSidecarRemovalFallsBackToFilename is the wipe-guard's +// testable sibling (faceted plan Phase 6 audit find): a single-file +// book whose sidecar is REMOVED must re-derive metadata through the +// create-equivalent chain (tags → filename), never blank the title. +// The pre-fix change path passed a non-nil empty metadata, which +// skipped the filename guard and the tag re-derivation. +func TestAudiobookSidecarRemovalFallsBackToFilename(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Removal %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + trackPath := filepath.Join(folder, "Fallback Book.mp3") + writeTestTrack(t, trackPath) + sidecar := filepath.Join(folder, "metadata.json") + require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Soon Gone Title"}`), 0o644)) + + scanTestFolder(t, setup, admin, folder) + book, found := findAudioItemByPath(t, setup, libID, "Fallback Book.mp3") + require.True(t, found) + require.Equal(t, "Soon Gone Title", book.Title, "sidecar title wins at create") + + // The sidecar vanishes: every sidecar gone → change → re-extract + // finds nothing → title falls back to the filename, not blank. + require.NoError(t, os.Remove(sidecar)) + scanTestFolder(t, setup, admin, folder) + + after := findAudioItemByPathRow(t, setup, libID, book.FilePath) + require.Equal(t, "Fallback Book", after.Title, "sidecar removal re-derives through the create-equivalent chain") +} + +// findItemUpdated reads a media item's updated_at by file_path. +func findItemUpdated(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) time.Time { + t.Helper() + for _, item := range listLibraryAudioItems(t, setup, libID) { + if item.FilePath == filePath { + return item.UpdatedAt.Time + } + } + t.Fatalf("item %q not found in library", filePath) + return time.Time{} +} + +// findAudioItemByPathRow is findAudioItemByPath with a hard failure. +func findAudioItemByPathRow(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) database.ListMediaItemsByLibraryIncludingArchivedRow { + t.Helper() + item, found := findAudioItemByPath(t, setup, libID, filePath) + require.True(t, found, "item %q must exist", filePath) + return item +} diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index d85f3922..0fa59348 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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 diff --git a/internal/services/media_scanner_audio_multifile.go b/internal/services/media_scanner_audio_multifile.go index 3de9f067..da00492b 100644 --- a/internal/services/media_scanner_audio_multifile.go +++ b/internal/services/media_scanner_audio_multifile.go @@ -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, + // "." 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: diff --git a/internal/services/media_scanner_manifest.go b/internal/services/media_scanner_manifest.go index 702ab4e3..3a273c45 100644 --- a/internal/services/media_scanner_manifest.go +++ b/internal/services/media_scanner_manifest.go @@ -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 "." +// 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 } diff --git a/internal/services/media_scanner_manifest_test.go b/internal/services/media_scanner_manifest_test.go index 02aee692..0d6b533b 100644 --- a/internal/services/media_scanner_manifest_test.go +++ b/internal/services/media_scanner_manifest_test.go @@ -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) {