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
@@ -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,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Set Library Visibility
|
name: Set Library Visibility
|
||||||
type: http
|
type: http
|
||||||
seq: 19
|
seq: 20
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Library
|
name: Update Library
|
||||||
type: http
|
type: http
|
||||||
seq: 20
|
seq: 21
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Scan Settings
|
name: Update Scan Settings
|
||||||
type: http
|
type: http
|
||||||
seq: 21
|
seq: 22
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
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 |
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Bookhoard API
|
title: Bookhoard API
|
||||||
version: '2026.10.24'
|
version: '2026.10.27'
|
||||||
description: |
|
description: |
|
||||||
Generated from the canonical API docs (docs/developer/api/**) by
|
Generated from the canonical API docs (docs/developer/api/**) by
|
||||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||||
@@ -1141,6 +1141,15 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
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":
|
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
||||||
post:
|
post:
|
||||||
tags: [libraries]
|
tags: [libraries]
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
|
|||||||
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
||||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
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": {
|
"DELETE /api/libraries/:id/issues/:issueId": {
|
||||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
|
|||||||
return nil
|
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)
|
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||||
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
|
|||||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||||
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
||||||
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
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.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
||||||
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
||||||
libraryID := c.Param("id")
|
libraryID := c.Param("id")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user