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

@@ -569,3 +569,213 @@ func TestRescanKeepsFilenameTitle(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title") 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
}
+29 -6
View File
@@ -1498,12 +1498,36 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
if err != nil { if err != nil {
return false, fmt.Errorf("sidecar change re-read failed: %v", err) return false, fmt.Errorf("sidecar change re-read failed: %v", err)
} }
// Extract once and pass it in, so the playlist sync below sees // Extract once; the result also carries the sidecar's playlist
// the sidecar's ordering entries (metadata_overrides stay // entries for the sync below.
// protected inside the update path either way).
metadata, extractErr := s.extractMetadata(path) metadata, extractErr := s.extractMetadata(path)
dir := bookSidecarDir(path)
if extractErr != nil || metadata == nil { 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 { 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) 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 // Playlist sync on the change path (plan Correction 2): a
// "(Reading Order)" entry added to an EXISTING book's sidecar // "(Reading Order)" entry added to an EXISTING book's sidecar
// still creates its collection — add-if-missing is idempotent. // 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 // Fresh full manifest: the next scan compares against TODAY's
// sidecars, not the stale pre-edit fingerprint. // sidecars, not the stale pre-edit fingerprint.
dir := bookSidecarDir(path)
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir))) s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
s.changedItems++ s.changedItems++
return false, nil return false, nil
@@ -840,7 +840,25 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
} }
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID) 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 existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil { if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err) 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) orderTracksByTags(probed)
// The representative path drives the sidecar/folder-structure checks; // The sentinel is defined above, next to the manifest check that
// they all look at Dir(representative), so a sentinel INSIDE the root // shares it; the probe pass reuses it as the extraction
// resolves covers and metadata.json living there (including disc- // representative.
// layout roots where track zero sits deeper).
sentinel := filepath.Join(rootDir, ".audiobook-root")
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir)) metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed) payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
metadata.AudioMeta = payload metadata.AudioMeta = payload
@@ -915,10 +931,34 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
switch { switch {
case err == nil: case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload) 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 { if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err 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 { } else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Unchanged content that was archived while the root was gone. // Unchanged content that was archived while the root was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil { 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) fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
} }
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries) 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) fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
s.newItems++ s.newItems++
default: default:
@@ -964,6 +1008,63 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
return nil 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. // processZipAudioBook ingests a zip archive of audio tracks as ONE book.
// The zip is the item's file (downloadable like any single file); // The zip is the item's file (downloadable like any single file);
// audio_metadata carries layout "zip" and per-entry track paths the // 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) 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 return nil
} }
@@ -1045,10 +1165,32 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
switch { switch {
case err == nil: case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload) 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 { if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err 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): case errors.Is(err, pgx.ErrNoRows):
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime) 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) fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
} }
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries) 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) fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
s.newItems++ s.newItems++
default: 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. // manifest after a change-path update.
func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool { func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool {
dir := bookSidecarDir(path) 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) stored := decodeSidecarManifest(entry.Manifest)
if len(computed.Entries) == 0 { if len(computed.Entries) == 0 {
@@ -240,7 +251,7 @@ func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry
if stored == nil { if stored == nil {
// Deploy-day backfill: hash + store WITHOUT re-extraction. // 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 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) { func TestReconcileSidecarManifest(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
write := func(name, content string) { write := func(name, content string) {