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:
John O'Keefe committed 2026-10-27 13:22:31 -04:00
1 parent fb117635f8
commit 6f37be59fc
10 files changed
+246 -4

No files matched your search

+3
View File
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"POST /api/libraries/:id/issues/:issueId/retry": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"DELETE /api/libraries/:id/issues/:issueId": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
+73
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"time"
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
return nil
}
// RetryProcessingIssue re-runs the work that produced an open issue
// through the individual scanner — NOT a library-wide rescan:
//
// - item-backed issues rescan their media item (the book-detail
// RescanMediaItem mechanism: self-resolving folders, multi-file
// audiobook routing, write-after-success guards);
// - row-less import_failed issues re-import the single file through
// the watcher's standalone processMediaFile path.
//
// Success closes the issue (explicitly for the item shape; the create
// path's self-heal plus the explicit path resolve below for row-less).
// Failure returns the REAL error so the UI can say why it still fails.
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
}
if !issue.LibraryID.Valid {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
if issue.Resolved.Valid && issue.Resolved.Bool {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
}
scanner := services.NewMediaScanner(h.db)
defer scanner.Close()
if issue.MediaItemID.Valid {
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Item rescanned; issue resolved",
})
}
// Row-less import failure: the file never became a row.
if !issue.FilePath.Valid || issue.FilePath.String == "" {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
}
relativePath := issue.FilePath.String
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
if errors.Is(err, services.ErrRetryFileMissing) {
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// The import chain's self-heal closes path-keyed issues on create;
// an already-imported duplicate (a previous partial retry) skips
// that path, so close explicitly here too. Idempotent.
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
LibraryID: issue.LibraryID,
FilePath: pgtype.Text{String: relativePath, Valid: true},
}); err != nil {
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "File imported; issue resolved",
})
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
+1
View File
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id")
+92
View File
@@ -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
}