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:
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
|
||||
}
|
||||
Reference in new issue
Block a user