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