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
@@ -1498,12 +1498,36 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sidecar change re-read failed: %v", err)
|
||||
}
|
||||
// Extract once and pass it in, so the playlist sync below sees
|
||||
// the sidecar's ordering entries (metadata_overrides stay
|
||||
// protected inside the update path either way).
|
||||
// Extract once; the result also carries the sidecar's playlist
|
||||
// entries for the sync below.
|
||||
metadata, extractErr := s.extractMetadata(path)
|
||||
dir := bookSidecarDir(path)
|
||||
if extractErr != nil || metadata == nil {
|
||||
metadata = &MediaMetadata{}
|
||||
// Extract-failure guard (faceted plan Phase 6 audit find):
|
||||
// UpdateMediaItem writes editorial columns unconditionally,
|
||||
// and preExtracted != nil disables the update path's
|
||||
// filename-title guard — a non-nil empty fallback would blank
|
||||
// the row. Keep the row untouched and refresh the manifest so
|
||||
// the failure isn't re-detected every scan; Phase 9's ledger
|
||||
// records the processing issue. No extractor error path is
|
||||
// reachable today (mergeMetadata never fails) — this is the
|
||||
// regression net for the class of bug the guard exists for.
|
||||
fmt.Printf("Warning: sidecar re-extraction failed for %s: %v — row left unchanged\n", path, extractErr)
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
return false, nil
|
||||
}
|
||||
// Playlist entries ride the FIRST extraction either way — capture
|
||||
// them before the reroute below.
|
||||
playlistSeries := metadata.PlaylistSeries
|
||||
if metadata.Title == "" {
|
||||
// Empty-success extraction (a tag-less book whose sidecar
|
||||
// vanished): preExtracted != nil would skip the update path's
|
||||
// filename-title guard AND the audio tag re-derivation —
|
||||
// blanking the title. Passing nil delegates to the function's
|
||||
// own create-equivalent chain instead (sidecars → folder
|
||||
// structure → tags → filename), mirroring the create path's
|
||||
// last rung.
|
||||
metadata = nil
|
||||
}
|
||||
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
||||
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
||||
@@ -1511,10 +1535,9 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
// Playlist sync on the change path (plan Correction 2): a
|
||||
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
||||
// still creates its collection — add-if-missing is idempotent.
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, metadata.PlaylistSeries)
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, playlistSeries)
|
||||
// Fresh full manifest: the next scan compares against TODAY's
|
||||
// sidecars, not the stale pre-edit fingerprint.
|
||||
dir := bookSidecarDir(path)
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
s.changedItems++
|
||||
return false, nil
|
||||
|
||||
Reference in new issue
Block a user