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.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
}