feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)
processing_issues only recorded cross-library move rejections; files that failed to import vanished into an error counter and stdout. The ledger now records every failure class and heals itself: Schema (in place, pre-release boundary): - media_item_id NULLABLE + file_path TEXT (deliberately unbounded — an over-long path is itself a failure mode; a VARCHAR(500) ledger column died on the exact 22001 it existed to report — caught by the new test). Row-less failures key on (library, file_path); item-backed on (item, type). Widened dedup = COALESCE-expression unique index; the old table UNIQUE stays (NULLs pairwise-distinct for row-less). - Conflict-target upsert: repeat failures UPDATE the same row and REOPEN resolved ones — never duplicates. Wirings: - CreateMediaItem failure (single-file + multifile createAudioBookItem) → row-less import_failed (error). - Extraction hard-fail with fallback import → item-backed metadata_extraction_failed (warning). - Change-path re-extraction failure (the ev/sidecar guard) → item-backed metadata_extraction_failed; LogProcessingIssue keeps its signature, a row-less helper joins it. Auto-resolution: - Successful import → ResolveOpenProcessingIssuesForPath (library, file_path). - Successful (re)extraction → ResolveOpenProcessingIssuesForItem (single-file change path, multifile editorial apply). - Unchanged-skip scans do NOT resolve: extraction never re-ran, so the degraded metadata is still degraded — the page heals exactly when the book does. Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR page): explicit out-of-set library id → 403, never silent. This tightens one pre-lens behavior — a nonexistent library id returned 200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md). List is LEFT-JOINed so row-less rows appear (media_item_id "", title falls back to the path). Tests: full lifecycle state machine through real scans (fail → appears, repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape); family lens 403s with a fresh non-shared admin; all 7 pre-existing issue suites green (two expectations widened to the lens, changelogged); adjacent suites (CrossLibraryMove, Audiobook, Calibre, ExtractorVersion, SchemaIdempotency) green; unit suites green.
This commit is contained in:
1 parent
19b9c7c9b8
commit
9b5defcb31
50 files changed
+1244
-681
No files matched your search
@@ -396,7 +396,7 @@ type PanelData struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues
|
||||
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
||||
type ProcessingIssues struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
@@ -407,6 +407,7 @@ type ProcessingIssues struct {
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
}
|
||||
|
||||
type ReaderSettings struct {
|
||||
|
||||
@@ -112,6 +112,11 @@ type Querier interface {
|
||||
// ============================================================================
|
||||
// PROCESSING ISSUES QUERIES
|
||||
// ============================================================================
|
||||
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||
// are nullable — item-backed rows key on (item, library, type, ''),
|
||||
// row-less import failures on (zero-uuid, library, type, path). The
|
||||
// conflict target matches idx_processing_issues_dedup's expressions.
|
||||
// A repeat failure REOPENS a resolved row (resolved=false).
|
||||
CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error)
|
||||
// Create reading history entry
|
||||
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
|
||||
@@ -392,6 +397,8 @@ type Querier interface {
|
||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
||||
// Get most popular books for a user
|
||||
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||
GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||
GetProcessingIssueStats(ctx context.Context, libraryID pgtype.UUID) (GetProcessingIssueStatsRow, error)
|
||||
GetReaderSettings(ctx context.Context, userID pgtype.UUID) ([]byte, error)
|
||||
// Get reading history for a user and book
|
||||
@@ -535,6 +542,10 @@ type Querier interface {
|
||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||
// silently disabled the offline flush until 2026-10-17).
|
||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||
// row — an inner join would silently drop the ledger's newest kind.
|
||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||
// the display file path falls back to the issue's own for row-less rows.
|
||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||
@@ -581,6 +592,12 @@ type Querier interface {
|
||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
// item successfully closes its open issues (extraction failures heal).
|
||||
ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error)
|
||||
// Row-less issues resolve by (library, file_path) when the file finally
|
||||
// imports — the page heals itself without admin action.
|
||||
ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error)
|
||||
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||
// Resolve unlinked book
|
||||
|
||||
@@ -1581,14 +1581,14 @@ func (q *Queries) CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateO
|
||||
|
||||
const CreateProcessingIssue = `-- name: CreateProcessingIssue :one
|
||||
|
||||
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (media_item_id, issue_type)
|
||||
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid), library_id, issue_type, COALESCE(file_path, ''))
|
||||
DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
||||
severity = EXCLUDED.severity,
|
||||
resolved = false,
|
||||
resolved_at = NULL
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
||||
`
|
||||
|
||||
type CreateProcessingIssueParams struct {
|
||||
@@ -1597,11 +1597,17 @@ type CreateProcessingIssueParams struct {
|
||||
IssueType string `db:"issue_type" json:"issue_type"`
|
||||
IssueDescription string `db:"issue_description" json:"issue_description"`
|
||||
Severity string `db:"severity" json:"severity"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROCESSING ISSUES QUERIES
|
||||
// ============================================================================
|
||||
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||
// are nullable — item-backed rows key on (item, library, type, ”),
|
||||
// row-less import failures on (zero-uuid, library, type, path). The
|
||||
// conflict target matches idx_processing_issues_dedup's expressions.
|
||||
// A repeat failure REOPENS a resolved row (resolved=false).
|
||||
func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error) {
|
||||
row := q.db.QueryRow(ctx, CreateProcessingIssue,
|
||||
arg.MediaItemID,
|
||||
@@ -1609,6 +1615,7 @@ func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessin
|
||||
arg.IssueType,
|
||||
arg.IssueDescription,
|
||||
arg.Severity,
|
||||
arg.FilePath,
|
||||
)
|
||||
var i ProcessingIssues
|
||||
err := row.Scan(
|
||||
@@ -1621,6 +1628,7 @@ func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessin
|
||||
&i.Resolved,
|
||||
&i.ResolvedAt,
|
||||
&i.CreatedAt,
|
||||
&i.FilePath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2201,7 +2209,7 @@ func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewPa
|
||||
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
|
||||
DELETE FROM processing_issues
|
||||
WHERE id = $1
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
|
||||
@@ -2217,6 +2225,7 @@ func (q *Queries) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (Pr
|
||||
&i.Resolved,
|
||||
&i.ResolvedAt,
|
||||
&i.CreatedAt,
|
||||
&i.FilePath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -8506,6 +8515,29 @@ func (q *Queries) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetProcessingIssue = `-- name: GetProcessingIssue :one
|
||||
SELECT id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path FROM processing_issues WHERE id = $1
|
||||
`
|
||||
|
||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||
func (q *Queries) GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
|
||||
row := q.db.QueryRow(ctx, GetProcessingIssue, id)
|
||||
var i ProcessingIssues
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.MediaItemID,
|
||||
&i.LibraryID,
|
||||
&i.IssueType,
|
||||
&i.IssueDescription,
|
||||
&i.Severity,
|
||||
&i.Resolved,
|
||||
&i.ResolvedAt,
|
||||
&i.CreatedAt,
|
||||
&i.FilePath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetProcessingIssueStats = `-- name: GetProcessingIssueStats :one
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
|
||||
@@ -12813,12 +12845,12 @@ SELECT
|
||||
pi.resolved,
|
||||
pi.resolved_at,
|
||||
pi.created_at,
|
||||
mi.title,
|
||||
mi.file_path,
|
||||
mi.format_group,
|
||||
COALESCE(mi.title, '') AS title,
|
||||
COALESCE(mi.file_path, pi.file_path, '') AS file_path,
|
||||
COALESCE(mi.format_group, '') AS format_group,
|
||||
lt.name as library_type_name
|
||||
FROM processing_issues pi
|
||||
JOIN media_items mi ON pi.media_item_id = mi.id
|
||||
LEFT JOIN media_items mi ON pi.media_item_id = mi.id
|
||||
JOIN libraries l ON pi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE pi.library_id = $1
|
||||
@@ -12847,6 +12879,10 @@ type ListProcessingIssuesByLibraryRow struct {
|
||||
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
||||
}
|
||||
|
||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||
// row — an inner join would silently drop the ledger's newest kind.
|
||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||
// the display file path falls back to the issue's own for row-less rows.
|
||||
func (q *Queries) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListProcessingIssuesByLibrary, libraryID)
|
||||
if err != nil {
|
||||
@@ -13629,13 +13665,56 @@ func (q *Queries) ResolveHashConflict(ctx context.Context, arg ResolveHashConfli
|
||||
return err
|
||||
}
|
||||
|
||||
const ResolveOpenProcessingIssuesForItem = `-- name: ResolveOpenProcessingIssuesForItem :execrows
|
||||
UPDATE processing_issues
|
||||
SET resolved = true,
|
||||
resolved_at = NOW()
|
||||
WHERE media_item_id = $1
|
||||
AND resolved = false
|
||||
`
|
||||
|
||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
// item successfully closes its open issues (extraction failures heal).
|
||||
func (q *Queries) ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForItem, mediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const ResolveOpenProcessingIssuesForPath = `-- name: ResolveOpenProcessingIssuesForPath :execrows
|
||||
UPDATE processing_issues
|
||||
SET resolved = true,
|
||||
resolved_at = NOW()
|
||||
WHERE media_item_id IS NULL
|
||||
AND library_id = $1
|
||||
AND file_path = $2
|
||||
AND resolved = false
|
||||
`
|
||||
|
||||
type ResolveOpenProcessingIssuesForPathParams struct {
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
}
|
||||
|
||||
// Row-less issues resolve by (library, file_path) when the file finally
|
||||
// imports — the page heals itself without admin action.
|
||||
func (q *Queries) ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForPath, arg.LibraryID, arg.FilePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const ResolveProcessingIssue = `-- name: ResolveProcessingIssue :one
|
||||
UPDATE processing_issues
|
||||
SET resolved = true,
|
||||
resolved_at = NOW()
|
||||
WHERE id = $1
|
||||
AND media_item_id = $2
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
|
||||
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
|
||||
`
|
||||
|
||||
type ResolveProcessingIssueParams struct {
|
||||
@@ -13656,6 +13735,7 @@ func (q *Queries) ResolveProcessingIssue(ctx context.Context, arg ResolveProcess
|
||||
&i.Resolved,
|
||||
&i.ResolvedAt,
|
||||
&i.CreatedAt,
|
||||
&i.FilePath,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -4208,9 +4208,14 @@ SELECT * FROM media_bookmarks WHERE id = $1;
|
||||
-- ============================================================================
|
||||
|
||||
-- name: CreateProcessingIssue :one
|
||||
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (media_item_id, issue_type)
|
||||
-- Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||
-- are nullable — item-backed rows key on (item, library, type, ''),
|
||||
-- row-less import failures on (zero-uuid, library, type, path). The
|
||||
-- conflict target matches idx_processing_issues_dedup's expressions.
|
||||
-- A repeat failure REOPENS a resolved row (resolved=false).
|
||||
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid), library_id, issue_type, COALESCE(file_path, ''))
|
||||
DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
||||
severity = EXCLUDED.severity,
|
||||
resolved = false,
|
||||
@@ -4218,6 +4223,10 @@ DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListProcessingIssuesByLibrary :many
|
||||
-- LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||
-- row — an inner join would silently drop the ledger's newest kind.
|
||||
-- Item-side projections COALESCE to keep them non-nullable strings;
|
||||
-- the display file path falls back to the issue's own for row-less rows.
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.media_item_id,
|
||||
@@ -4227,12 +4236,12 @@ SELECT
|
||||
pi.resolved,
|
||||
pi.resolved_at,
|
||||
pi.created_at,
|
||||
mi.title,
|
||||
mi.file_path,
|
||||
mi.format_group,
|
||||
COALESCE(mi.title, '') AS title,
|
||||
COALESCE(mi.file_path, pi.file_path, '') AS file_path,
|
||||
COALESCE(mi.format_group, '') AS format_group,
|
||||
lt.name as library_type_name
|
||||
FROM processing_issues pi
|
||||
JOIN media_items mi ON pi.media_item_id = mi.id
|
||||
LEFT JOIN media_items mi ON pi.media_item_id = mi.id
|
||||
JOIN libraries l ON pi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE pi.library_id = $1
|
||||
@@ -4245,6 +4254,30 @@ ORDER BY
|
||||
END,
|
||||
pi.created_at DESC;
|
||||
|
||||
-- name: ResolveOpenProcessingIssuesForItem :execrows
|
||||
-- Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
-- item successfully closes its open issues (extraction failures heal).
|
||||
UPDATE processing_issues
|
||||
SET resolved = true,
|
||||
resolved_at = NOW()
|
||||
WHERE media_item_id = $1
|
||||
AND resolved = false;
|
||||
|
||||
-- name: ResolveOpenProcessingIssuesForPath :execrows
|
||||
-- Row-less issues resolve by (library, file_path) when the file finally
|
||||
-- imports — the page heals itself without admin action.
|
||||
UPDATE processing_issues
|
||||
SET resolved = true,
|
||||
resolved_at = NOW()
|
||||
WHERE media_item_id IS NULL
|
||||
AND library_id = $1
|
||||
AND file_path = $2
|
||||
AND resolved = false;
|
||||
|
||||
-- name: GetProcessingIssue :one
|
||||
-- Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||
SELECT * FROM processing_issues WHERE id = $1;
|
||||
|
||||
-- name: GetProcessingIssueStats :one
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"bookhoard/internal/services"
|
||||
)
|
||||
|
||||
type ProcessingIssuesHandler struct {
|
||||
@@ -19,6 +21,45 @@ func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler {
|
||||
return &ProcessingIssuesHandler{db: db}
|
||||
}
|
||||
|
||||
// visibleLibrarySetFor resolves the requesting user's visible-library
|
||||
// set (family-lens) from the request context identity. The issues
|
||||
// endpoints leak media metadata (titles, paths) — a hidden library's
|
||||
// rows must never reach a user it's hidden from, same rule as every
|
||||
// media surface. Identity follows the router's visibleLibraryIDsFor
|
||||
// (the JWT middleware sets "user_id"; AdminMiddleware only reads the
|
||||
// role — there is no "user" object on this chain).
|
||||
func (h *ProcessingIssuesHandler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
|
||||
userUUIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
userU, err := uuid.Parse(userUUIDStr)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
|
||||
c.Request().Context(), pgtype.UUID{Bytes: userU, Valid: true})
|
||||
return set, err
|
||||
}
|
||||
|
||||
// gateLibrary enforces the family lens on a library-scoped request:
|
||||
// an explicit library id outside the requester's visible set is a 403,
|
||||
// never a silent pass (faceted plan family-lens rule).
|
||||
func (h *ProcessingIssuesHandler) gateLibrary(c *echo.Context, libraryIDStr string) error {
|
||||
libUUID, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid library ID")
|
||||
}
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
|
||||
}
|
||||
if !visibleSet[libUUID.String()] {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProcessingIssueResponse struct {
|
||||
ID string `json:"id"`
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
@@ -40,10 +81,10 @@ type ProcessingIssueStats struct {
|
||||
|
||||
// ListProcessingIssues returns all processing issues for a library
|
||||
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
if err := h.gateLibrary(c, c.Param("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
libraryID, _ := uuid.Parse(c.Param("id"))
|
||||
|
||||
issues, err := h.db.ListProcessingIssuesByLibrary(
|
||||
c.Request().Context(),
|
||||
@@ -55,10 +96,21 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
response := make([]ProcessingIssueResponse, len(issues))
|
||||
for i, issue := range issues {
|
||||
// Row-less import failures have no media item — emit an empty id
|
||||
// rather than the zero UUID, and fall back the display title to
|
||||
// the file path so the ledger reads like itself.
|
||||
itemID := ""
|
||||
if issue.MediaItemID.Valid {
|
||||
itemID = issue.MediaItemID.String()
|
||||
}
|
||||
title := issue.Title
|
||||
if title == "" {
|
||||
title = issue.FilePath
|
||||
}
|
||||
response[i] = ProcessingIssueResponse{
|
||||
ID: issue.ID.String(),
|
||||
MediaItemID: issue.MediaItemID.String(),
|
||||
Title: issue.Title,
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
@@ -74,10 +126,10 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
// GetProcessingIssueStats returns statistics about processing issues
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
if err := h.gateLibrary(c, c.Param("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
libraryID, _ := uuid.Parse(c.Param("id"))
|
||||
|
||||
stats, err := h.db.GetProcessingIssueStats(
|
||||
c.Request().Context(),
|
||||
@@ -106,6 +158,10 @@ func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c *echo.Context) error
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
if err := h.gateIssue(c, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.ResolveProcessingIssue(
|
||||
c.Request().Context(),
|
||||
database.ResolveProcessingIssueParams{
|
||||
@@ -130,6 +186,10 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
|
||||
}
|
||||
|
||||
if err := h.gateIssue(c, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.DeleteProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"})
|
||||
@@ -141,6 +201,23 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// gateIssue enforces the family lens on an issue-addressed request: the
|
||||
// issue's library must be in the requester's visible set (403 otherwise).
|
||||
func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID) error {
|
||||
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Issue not found")
|
||||
}
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
|
||||
}
|
||||
if !issue.LibraryID.Valid || !visibleSet[uuid.UUID(issue.LibraryID.Bytes).String()] {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -1223,6 +1223,15 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_id")
|
||||
}
|
||||
// Family lens: issues leak media metadata (titles, file paths) —
|
||||
// a library outside the requester's visible set renders nothing.
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
return renderErrorPage(c, "Error checking library access", "visibility_error")
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
|
||||
}
|
||||
// Fetch stats
|
||||
stats, err := cfg.Queries.GetProcessingIssueStats(
|
||||
c.Request().Context(),
|
||||
@@ -1239,14 +1248,23 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading issues", "issues_load_error")
|
||||
}
|
||||
// Convert to template data
|
||||
// Convert to template data (row-less import failures have no
|
||||
// media item: empty id, and the title falls back to the path).
|
||||
issues := make([]templates.ProcessingIssueData, len(dbIssues))
|
||||
for i, issue := range dbIssues {
|
||||
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
|
||||
itemID := ""
|
||||
if issue.MediaItemID.Valid {
|
||||
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
|
||||
}
|
||||
title := issue.Title
|
||||
if title == "" {
|
||||
title = issue.FilePath
|
||||
}
|
||||
issues[i] = templates.ProcessingIssueData{
|
||||
ID: issueUUID.String(),
|
||||
MediaItemID: uuid.UUID(issue.MediaItemID.Bytes).String(),
|
||||
Title: issue.Title,
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
|
||||
@@ -1134,9 +1134,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
// proceed straight to extraction + create.
|
||||
|
||||
// Extract metadata from file first
|
||||
metadata, err := s.extractMetadata(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
|
||||
metadata, extractErr := s.extractMetadata(path)
|
||||
if extractErr != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, extractErr)
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
|
||||
@@ -1351,9 +1351,36 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
})
|
||||
if err != nil {
|
||||
// Ledger (faceted plan Phase 11): the file's media_items row was
|
||||
// never created — record it ROW-LESS keyed by (library, path) so
|
||||
// the Processing Issues page shows the failure; the file's next
|
||||
// successful import resolves it.
|
||||
if logErr := s.LogRowlessProcessingIssue(ctx,
|
||||
uuid.UUID(libraryID.Bytes), relativePath,
|
||||
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relativePath, logErr)
|
||||
}
|
||||
return false, fmt.Errorf("failed to create media item: %v", err)
|
||||
}
|
||||
|
||||
// Ledger self-heal (Phase 11): the file finally imports — close any
|
||||
// open row-less import-failure issues keyed to this (library, path).
|
||||
s.resolveIssuesForPath(ctx, libraryID, relativePath)
|
||||
|
||||
// Extraction-fallback import (Phase 11 ledger): extraction hard-failed
|
||||
// but the import proceeded on fallback metadata (folder structure →
|
||||
// filename → "Unknown"). The item EXISTS — record the issue
|
||||
// item-backed as a warning so the page can flag degraded metadata.
|
||||
if extractErr != nil {
|
||||
if logErr := s.LogProcessingIssue(ctx,
|
||||
uuid.UUID(createdItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||
"metadata_extraction_failed",
|
||||
fmt.Sprintf("Metadata extraction failed; imported with fallback metadata: %v", extractErr),
|
||||
"warning"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record extraction issue for %s: %v\n", relativePath, logErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update hash information before database storage
|
||||
if metadata.FileHashInfo != nil && metadata.FileHashInfo.FileSHA256 != "" {
|
||||
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
||||
@@ -1564,12 +1591,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
// reachable today (mergeMetadata never fails) — this is the
|
||||
// regression net for the class of bug the guard exists for.
|
||||
fmt.Printf("Warning: sidecar re-extraction failed for %s: %v — row left unchanged\n", path, extractErr)
|
||||
// Baseline store, never a stamp: a stamp would record the
|
||||
// current parser version on a row the current parser never
|
||||
// successfully processed — for an ev-triggered change that
|
||||
// would PERMANENTLY skip the fix. Leaving ev stale retries
|
||||
// on the next poll (budget-bounded).
|
||||
// Ledger (Phase 11): the item exists — record item-backed so
|
||||
// the page flags degraded metadata. Baseline store, never a
|
||||
// stamp: a stamp would record the current parser version on
|
||||
// a row the current parser never successfully processed —
|
||||
// for an ev-triggered change that would PERMANENTLY skip the
|
||||
// fix. Leaving ev stale retries on the next poll
|
||||
// (budget-bounded).
|
||||
s.storeManifestBaseline(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
if logErr := s.LogProcessingIssue(ctx,
|
||||
uuid.UUID(entry.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||
"metadata_extraction_failed",
|
||||
fmt.Sprintf("Metadata re-extraction failed; row left unchanged: %v", extractErr),
|
||||
"warning"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record extraction issue for %s: %v\n", path, logErr)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
// Playlist entries ride the FIRST extraction either way — capture
|
||||
@@ -1595,6 +1631,9 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
// Fresh full manifest: the next scan compares against TODAY's
|
||||
// sidecars, not the stale pre-edit fingerprint.
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
||||
// Ledger self-heal (Phase 11): the item processed successfully —
|
||||
// close its open issues (a healed extraction failure clears).
|
||||
s.resolveIssuesForItem(ctx, entry.ID)
|
||||
s.changedItems++
|
||||
return false, nil
|
||||
}
|
||||
@@ -2693,6 +2732,8 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
|
||||
}
|
||||
|
||||
// LogProcessingIssue records items that can't be processed properly
|
||||
// (item-backed: the media_items row exists). Row-less import failures
|
||||
// use LogRowlessProcessingIssue.
|
||||
func (s *MediaScanner) LogProcessingIssue(
|
||||
ctx context.Context,
|
||||
mediaItemID uuid.UUID,
|
||||
@@ -2707,10 +2748,61 @@ func (s *MediaScanner) LogProcessingIssue(
|
||||
IssueType: issueType,
|
||||
IssueDescription: description,
|
||||
Severity: severity,
|
||||
// Item-backed rows carry no file_path of their own — the item
|
||||
// row already has it; the list COALESCEs.
|
||||
FilePath: pgtype.Text{},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// LogRowlessProcessingIssue records an import failure for a file whose
|
||||
// media_items row was never created (faceted plan Phase 11): keyed
|
||||
// row-less by (library, file_path). The file's next SUCCESSFUL import
|
||||
// resolves the issue (ResolveOpenProcessingIssuesForPath).
|
||||
func (s *MediaScanner) LogRowlessProcessingIssue(
|
||||
ctx context.Context,
|
||||
libraryID uuid.UUID,
|
||||
filePath string,
|
||||
issueType string,
|
||||
description string,
|
||||
severity string,
|
||||
) error {
|
||||
_, err := s.db.CreateProcessingIssue(ctx, database.CreateProcessingIssueParams{
|
||||
MediaItemID: pgtype.UUID{},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
IssueType: issueType,
|
||||
IssueDescription: description,
|
||||
Severity: severity,
|
||||
FilePath: pgtype.Text{String: filePath, Valid: filePath != ""},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveIssuesForItem closes the item's open ledger rows after a
|
||||
// successful (re)extraction — the page self-heals (Phase 11).
|
||||
func (s *MediaScanner) resolveIssuesForItem(ctx context.Context, itemID pgtype.UUID) {
|
||||
if !itemID.Valid {
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ResolveOpenProcessingIssuesForItem(ctx, itemID); err != nil {
|
||||
fmt.Printf("Warning: failed to auto-resolve issues for item %s: %v\n", itemID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveIssuesForPath closes row-less import-failure issues keyed to
|
||||
// (library, path) after the file finally imports (Phase 11).
|
||||
func (s *MediaScanner) resolveIssuesForPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) {
|
||||
if !libraryID.Valid || relativePath == "" {
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ResolveOpenProcessingIssuesForPath(ctx, database.ResolveOpenProcessingIssuesForPathParams{
|
||||
LibraryID: libraryID,
|
||||
FilePath: pgtype.Text{String: relativePath, Valid: true},
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to auto-resolve import issues for %s: %v\n", relativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseCalibreMetadataOPF parses a Calibre metadata.opf sidecar file.
|
||||
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
||||
// Open file
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
@@ -536,7 +537,7 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
// A2c §2.7: same title/subtitle-derived series fallback as the
|
||||
// single-file path — folder-of-tracks books carry the same naming.
|
||||
applyTitleDerivedSeries(metadata)
|
||||
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
@@ -566,6 +567,20 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
})
|
||||
if err != nil {
|
||||
// Ledger (Phase 11): row-less import failure keyed by
|
||||
// (library, path) — the folder/zip book's row was never created.
|
||||
if logErr := s.LogRowlessProcessingIssue(ctx,
|
||||
uuid.UUID(libraryID.Bytes), relPath,
|
||||
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relPath, logErr)
|
||||
}
|
||||
return database.MediaItems{}, err
|
||||
}
|
||||
// Ledger self-heal (Phase 11): the book finally imports — close any
|
||||
// open row-less import-failure issues keyed to this (library, path).
|
||||
s.resolveIssuesForPath(ctx, libraryID, relPath)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// setAudioFormatGroup writes the audiobook format facts after create.
|
||||
@@ -942,6 +957,10 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
if manifestChanged && !s.forceRescan {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
} else {
|
||||
// Ledger self-heal (Phase 11): the book's metadata
|
||||
// re-applied successfully — close its open issues.
|
||||
s.resolveIssuesForItem(ctx, existing.ID)
|
||||
}
|
||||
// Fresh full manifest: the next scan compares against
|
||||
// TODAY's sidecars, not the stale pre-edit fingerprint.
|
||||
|
||||
Reference in new issue
Block a user