From fb117635f8a3c83fa4eb84c2cb4d98b43a8df663 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 13:21:48 -0400 Subject: [PATCH] feat(scanner): ledger failed metadata updates; guard partial-scope archive passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/services/media_scanner.go | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index d68eda82..34d50efb 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -844,6 +844,33 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { } 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) if err != nil { 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 { 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. 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 { 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 // "(Reading Order)" entry added to an EXISTING book's sidecar