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:
John O'Keefe committed 2026-10-20 19:38:02 -04:00
1 parent 19b9c7c9b8
commit 9b5defcb31
50 files changed
+1244 -681

No files matched your search

+100 -8
View File
@@ -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.