From 6f37be59fc34118a17ef56ee678ad32b37900533 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 13:22:31 -0400 Subject: [PATCH] feat(issues): retry a processing issue through the individual scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bruno/libraries/retry_processing_issue.yml | 23 +++++ bruno/libraries/set_library_visibility.yml | 2 +- bruno/libraries/update_library.yml | 2 +- bruno/libraries/update_scan_settings.yml | 2 +- .../api/libraries/retry_processing_issue.md | 41 +++++++++ docs/developer/openapi.yaml | 11 ++- internal/docs/schema.go | 3 + internal/handlers/processing_issues.go | 73 +++++++++++++++ internal/router/library.go | 1 + internal/services/media_scanner_retry.go | 92 +++++++++++++++++++ 10 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 bruno/libraries/retry_processing_issue.yml create mode 100644 docs/developer/api/libraries/retry_processing_issue.md create mode 100644 internal/services/media_scanner_retry.go diff --git a/bruno/libraries/retry_processing_issue.yml b/bruno/libraries/retry_processing_issue.yml new file mode 100644 index 00000000..3e826a6e --- /dev/null +++ b/bruno/libraries/retry_processing_issue.yml @@ -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 diff --git a/bruno/libraries/set_library_visibility.yml b/bruno/libraries/set_library_visibility.yml index 24a2b1c5..cb97684a 100644 --- a/bruno/libraries/set_library_visibility.yml +++ b/bruno/libraries/set_library_visibility.yml @@ -1,7 +1,7 @@ info: name: Set Library Visibility type: http - seq: 19 + seq: 20 http: method: POST diff --git a/bruno/libraries/update_library.yml b/bruno/libraries/update_library.yml index 9aef0093..60a36b44 100644 --- a/bruno/libraries/update_library.yml +++ b/bruno/libraries/update_library.yml @@ -1,7 +1,7 @@ info: name: Update Library type: http - seq: 20 + seq: 21 http: method: PUT diff --git a/bruno/libraries/update_scan_settings.yml b/bruno/libraries/update_scan_settings.yml index 9c8f9285..3b4b543b 100644 --- a/bruno/libraries/update_scan_settings.yml +++ b/bruno/libraries/update_scan_settings.yml @@ -1,7 +1,7 @@ info: name: Update Scan Settings type: http - seq: 21 + seq: 22 http: method: PUT diff --git a/docs/developer/api/libraries/retry_processing_issue.md b/docs/developer/api/libraries/retry_processing_issue.md new file mode 100644 index 00000000..5dc956f1 --- /dev/null +++ b/docs/developer/api/libraries/retry_processing_issue.md @@ -0,0 +1,41 @@ +--- +method: POST +path: /api/libraries/:id/issues/:issueId/retry +auth: jwt+admin +area: libraries +--- + + +# 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 | diff --git a/docs/developer/openapi.yaml b/docs/developer/openapi.yaml index 46518964..738d0d6b 100644 --- a/docs/developer/openapi.yaml +++ b/docs/developer/openapi.yaml @@ -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] diff --git a/internal/docs/schema.go b/internal/docs/schema.go index c6a6849a..70ba25f2 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -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": ""}`), }, diff --git a/internal/handlers/processing_issues.go b/internal/handlers/processing_issues.go index 7995c686..ee591c31 100644 --- a/internal/handlers/processing_issues.go +++ b/internal/handlers/processing_issues.go @@ -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) diff --git a/internal/router/library.go b/internal/router/library.go index b86cb4a8..1343b6b6 100644 --- a/internal/router/library.go +++ b/internal/router/library.go @@ -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") diff --git a/internal/services/media_scanner_retry.go b/internal/services/media_scanner_retry.go new file mode 100644 index 00000000..06a2c200 --- /dev/null +++ b/internal/services/media_scanner_retry.go @@ -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 +}