feat(scanner): ledger failed metadata updates; guard partial-scope archive passes

Two scanner-integrity changes supporting the Processing Issues retry work:

- The size-change and sidecar-change update paths only printf'd their
  failures, so a book whose metadata update kept failing (the Crusader
  genre-22001 loop) degraded silently on every scan with nothing on the
  Processing Issues page. Both paths now record an item-backed
  metadata_extraction_failed issue — the same type and shape the
  sidecar-extraction guard already used — and resolveIssuesForItem
  clears it when an update finally succeeds.

- The archive reconciliation pass compares every library item against
  what one scan saw on disk. The new per-folder scan scope runs a scan
  covering a single folder, which would mark every item under the
  library's OTHER folders missing and archive them on a later pass.
  The pass now skips a library whose registered folders the scan only
  partially covered (GetLibraryFolders vs the scan's folder set),
  logging the skip.
This commit is contained in:
John O'Keefe committed 2026-10-27 13:21:48 -04:00
1 parent 885d614092
commit fb117635f8
1 file changed
+54
+54
View File
@@ -844,6 +844,33 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
} }
libraryID := lib.LibraryID libraryID := lib.LibraryID
// Partial-scope guard (per-folder scans, 2026-10): the
// reconciliation compares EVERY library item against this scan's
// disk walk — a scan that covered only some of the library's
// registered folders (the LibraryPanel per-folder Scan buttons)
// saw nothing from the others and would mark their items missing
// → archived. Reconcile only when the scan covered all of them.
registeredFolders, regErr := s.db.GetLibraryFolders(ctx, libraryID)
if regErr != nil {
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
continue
}
scannedSet := make(map[string]bool, len(s.folders))
for _, scanned := range s.folders {
scannedSet[scanned] = true
}
covered := 0
for _, rf := range registeredFolders {
if scannedSet[rf.FolderPath] {
covered++
}
}
if covered < len(registeredFolders) {
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
lib.Name, covered, len(registeredFolders))
continue
}
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID) dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil { if err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err) fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
@@ -1577,6 +1604,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
} }
if err := s.updateMediaItem(ctx, existingItem, path); err != nil { if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update media item: %v\n", err) fmt.Printf("Warning: failed to update media item: %v\n", err)
// Ledger (Phase 11): the item exists — record item-backed so
// the Processing Issues page flags the degraded metadata and
// offers a retry, instead of failing silently on every scan
// (the 2026-10 Crusader genre-22001 loop did exactly that).
// Same type the sidecar-change path uses; resolveIssuesForItem
// clears it when an update finally succeeds.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
} }
// The bytes changed, so any stored hash is stale. // The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, entry.ID, libraryID, path) s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
@@ -1643,6 +1685,18 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
} }
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)
// Ledger: same item-backed record as the size-change path —
// a failed re-extraction must be visible and retryable, not a
// scan-log whisper.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
} }
// 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