feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.
- Sidecar manifest fingerprints (media_scanner_manifest.go): the
manifest stats exactly the names the extractors consume (metadata.opf,
metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
manifest and extractors can never disagree. Shape v1:
{v, entries:[{n, s, m (unix nanos — second granularity would hide
edits landing in the backfill scan's own second), h (sha256 — covers
included: the repoint ladder needs a baseline hash for any stat-
drifted sidecar; a hashless cover turns harmless mtime drift into a
re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
reconciliation, stat pair refreshed, no re-extraction; differing =
changed); NULL stored manifest → backfill only, NO re-extraction
(deploy-day rule: the first scan after release must not look like an
mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
through updateMediaItemWithMetadata (preExtracted — metadata_overrides
stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
a reading-order entry added to an EXISTING book now creates its
collection; add-if-missing is idempotent), store a fresh full
manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
change path does not consume sidecar extractors, so gating them would
flip books 'changed' without picking anything up (plan correction —
the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.
Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
This commit is contained in:
1 parent
693053a9f5
commit
6750e9e0dc
9 files changed
+633
-14
No files matched your search
@@ -331,10 +331,10 @@ func (s *MediaScanner) syncPlaylistCollectionsAfterCreate(ctx context.Context, i
|
||||
}
|
||||
position := cleanIntegerPosition(entry.Sequence)
|
||||
if _, err := s.db.AddBookToCollectionOrdered(ctx, database.AddBookToCollectionOrderedParams{
|
||||
CollectionID: collection.ID,
|
||||
MediaItemID: itemID,
|
||||
AddedByUserID: s.adminID,
|
||||
Position: pgtype.Int4{Int32: position, Valid: position > 0},
|
||||
CollectionID: collection.ID,
|
||||
MediaItemID: itemID,
|
||||
AddedByUserID: s.adminID,
|
||||
Position: pgtype.Int4{Int32: position, Valid: position > 0},
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: playlist collection add failed for %q: %v\n", entry.Name, err)
|
||||
continue
|
||||
@@ -350,7 +350,7 @@ func (s *MediaScanner) syncPlaylistCollectionsAfterCreate(ctx context.Context, i
|
||||
func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry SeriesCandidate) (database.Collections, bool, error) {
|
||||
owner := s.adminID
|
||||
if collection, err := s.db.GetCollectionByImportedSeries(ctx, database.GetCollectionByImportedSeriesParams{
|
||||
UserID: owner,
|
||||
UserID: owner,
|
||||
ImportedFromSeries: entry.Name,
|
||||
}); err == nil {
|
||||
return collection, false, nil
|
||||
@@ -1311,12 +1311,21 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
if metadata.FileHashInfo != nil {
|
||||
sha = metadata.FileHashInfo.FileSHA256
|
||||
}
|
||||
s.scanStateUpsert(libraryID, &scanEntry{
|
||||
ID: createdItem.ID,
|
||||
FilePath: relativePath,
|
||||
FileSize: info.Size(),
|
||||
newEntry := &scanEntry{
|
||||
ID: createdItem.ID,
|
||||
FilePath: relativePath,
|
||||
FileSize: info.Size(),
|
||||
FileSha256: sha,
|
||||
})
|
||||
}
|
||||
s.scanStateUpsert(libraryID, newEntry)
|
||||
|
||||
// Seed the sidecar manifest at create time (Part 2 §3): the next
|
||||
// scan compares instead of backfilling, so an edit is detected on
|
||||
// its first post-seed scan.
|
||||
dir := bookSidecarDir(path)
|
||||
if computed := statSidecarManifest(path, dir); computed != nil && len(computed.Entries) > 0 {
|
||||
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(path, dir, computed))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -1418,6 +1427,37 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Manifest-gated sidecar detection (Part 2 §3): a size-stable book
|
||||
// is changed by EDITING its sidecars. NULL stored manifest →
|
||||
// backfill only; stat drift with equal hashes → repoint
|
||||
// reconciliation; content drift → re-extract below.
|
||||
if s.detectSidecarChange(ctx, entry, libraryID, path) {
|
||||
fmt.Printf("Sidecar changed, re-extracting metadata: %s\n", path)
|
||||
existingItem, err := s.getMediaItemByFilePath(ctx, path, libraryID)
|
||||
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).
|
||||
metadata, extractErr := s.extractMetadata(path)
|
||||
if extractErr != nil || metadata == nil {
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
// 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)))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Self-heal items imported before hashing existed: even an unchanged
|
||||
// file gets its hash computed if missing. recomputeHashInfo persists
|
||||
// it; the next scan's bulk load carries it — no write-through needed
|
||||
|
||||
Reference in new issue
Block a user