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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user