feat(issues): retry a processing issue through the individual scanner
POST /api/libraries/:id/issues/:issueId/retry (admin, family-lens gated via the existing gateIssue) re-runs the work that produced an open issue — deliberately NOT a library-wide rescan. Two shapes mirror the two ledger entry kinds: - item-backed issues rescan their media item through the book-detail RescanMediaItem mechanism (self-resolving folders, multi-file audiobook routing, write-after-success guards) and then close the issue (RetryMediaItemIssue); - row-less import_failed issues re-import the single file through the watcher's standalone processMediaFile path (RetryFailedImport: folders resolved RescanMediaItem-style so paths stay library-root-relative), whose create-path self-heal plus an explicit path resolve closes the issue even when the content was already imported by a prior attempt. Failures return the REAL underlying error so the UI can show why the issue still exists; a vanished file is a 409 (ErrRetryFileMissing); already-resolved issues reject re-retry. Docs + bruno mirrors regenerated for the new route (the generator's seq renumbering touches three unrelated bruno files as fallout; openapi gains the path and the date bump), and the docs schema registry gains the retry entry so TestDocsMatchRoutes passes.
This commit is contained in:
1 parent
fb117635f8
commit
6f37be59fc
10 files changed
+246
-4
No files matched your search
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user