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:
1 parent
885d614092
commit
fb117635f8
1 file changed
+54
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user