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.
93 lines
3.4 KiB
Go
93 lines
3.4 KiB
Go
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
|
|
}
|