package services // Individual-item retry (2026-10 Processing Issues work): the per-issue // "Retry" button re-runs the failed work through the individual scanner // instead of a whole-library rescan. Two shapes mirror the two ledger // entry kinds: // // - item-backed issues → RescanMediaItem (the book-detail rescan // mechanism; folder resolution, multi-file audiobook routing and the // write-after-success guards all live there already) // - row-less import_failed issues → RetryFailedImport below: the // watcher's standalone per-file path (processMediaFile with no bulk // scan state), scoped to exactly one file. import ( "context" "errors" "fmt" "os" "path/filepath" "github.com/jackc/pgx/v5/pgtype" ) // ErrRetryFileMissing reports a retry whose file is no longer on disk — // the handler maps it to 409 so the UI can say "file gone" rather than // "retry failed". var ErrRetryFileMissing = errors.New("media file not found on disk") // RetryFailedImport re-imports a single file whose media_items row was // never created (a row-less import_failed ledger entry). It resolves the // library's folders itself (the RescanMediaItem precedent) so relative // paths stay library-root-relative, then runs the ordinary single-file // import chain — sidecars → folder structure → tags → filename — with // the create path's own ledger self-heal (resolveIssuesForPath) closing // the issue on success. func (s *MediaScanner) RetryFailedImport(ctx context.Context, libraryID pgtype.UUID, relativePath string) error { if !libraryID.Valid || relativePath == "" { return fmt.Errorf("library id and file path are required") } folders, err := s.db.GetLibraryFolders(ctx, libraryID) if err != nil || len(folders) == 0 { return fmt.Errorf("no library folders found for library") } folderPaths := make([]string, 0, len(folders)) for _, folder := range folders { folderPaths = append(folderPaths, folder.FolderPath) } s.folders = folderPaths // processMediaFile prefers the default library over per-folder // resolution; setting it keeps a path that straddles folder roots // attributed to the ledger entry's library. s.SetLibraryID(libraryID) var fullPath string for _, folder := range folders { candidate := filepath.Join(folder.FolderPath, relativePath) if _, statErr := os.Stat(candidate); statErr == nil { fullPath = candidate break } } if fullPath == "" { return fmt.Errorf("%w: %s", ErrRetryFileMissing, relativePath) } imported, err := s.processMediaFile(ctx, fullPath) if err != nil { return err } if !imported { return fmt.Errorf("file was skipped (not a scannable media file, or identical content already registered)") } return nil } // RetryMediaItemIssue re-extracts one item-backed issue's media item // through the book-detail rescan mechanism and closes the issue when the // rescan succeeds. Kept next to RetryFailedImport so the retry handler // reads as the one piece of new surface it is. func (s *MediaScanner) RetryMediaItemIssue(ctx context.Context, mediaItemID pgtype.UUID) error { if err := s.RescanMediaItem(ctx, mediaItemID, false); err != nil { return err } // RescanMediaItem doesn't self-heal the ledger; the retry that was // explicitly asked for does. if _, err := s.db.ResolveOpenProcessingIssuesForItem(ctx, mediaItemID); err != nil { fmt.Printf("Warning: retry resolved item %s but failed to close its issues: %v\n", mediaItemID, err) } return nil }