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

@@ -0,0 +1,23 @@
info:
name: Retry Processing Issue
type: http
seq: 18
http:
method: POST
url: '{{base_url}}/api/libraries/{{ebook_library_id}}/issues/{{issueid}}/retry'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Retry Processing Issue
`POST /api/libraries/:id/issues/:issueId/retry` — auth: jwt+admin.
Canonical reference: docs/developer/api/libraries/retry_processing_issue.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Set Library Visibility
type: http
seq: 19
seq: 20
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Library
type: http
seq: 20
seq: 21
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Scan Settings
type: http
seq: 21
seq: 22
http:
method: PUT
@@ -0,0 +1,41 @@
---
method: POST
path: /api/libraries/:id/issues/:issueId/retry
auth: jwt+admin
area: libraries
---
<!-- source: internal/handlers/processing_issues.go RetryProcessingIssue -->
# Retry Processing Issue
Re-runs the work that produced an open issue through the individual
scanner — deliberately NOT a library-wide rescan. Two shapes:
- **Item-backed issues** (media item exists): the item is rescanned via
the same mechanism as the book-detail Rescan action — self-resolving
folders, multi-file audiobook routing, write-after-success guards.
- **Row-less `import_failed` issues** (no media item): the single file
is re-imported through the scanner's standalone per-file path.
On success the issue is closed. On failure the response carries the REAL
error (e.g. the underlying SQL failure), so the UI can show why the issue
still exists. The issue's library must be in the requester's visible set.
## Example response
```json example-response
{
"success": true,
"message": "File imported; issue resolved"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid issue ID"}` | malformed path param |
| 403 | — | issue's library not in requester's visible set |
| 404 | `{"error": "Issue not found"}` | no such issue |
| 409 | `{"error": "Issue is already resolved"}` / `{"error": "media file not found on disk: …"}` | nothing to retry, or the file vanished |
| 500 | `{"error": "…"}` | the retried work failed — body carries the underlying error |
+10 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Bookhoard API
version: '2026.10.24'
version: '2026.10.27'
description: |
Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -1141,6 +1141,15 @@ paths:
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/retry":
post:
tags: [libraries]
summary: "Retry Processing Issue"
security:
- bearerAuth: []
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
post:
tags: [libraries]
+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
}