* fix(metadata): publish artwork revisions atomically * fix(metadata): harden artwork revision cleanup * fix(metadata): address artwork revision review findings - restore image applies for all media_items types and reject unsupported target/image combinations with 400 before uploading; episodes coerce to stills and the web dialog no longer offers image tabs episodes can't use - add WHEN clauses to displacement triggers and hoist to_jsonb so bulk catalog upserts that assign unchanged artwork columns skip the trigger - make artworkkey the single variant-ladder owner: imagecache derives its widths from it and triggers store image_type instead of hardcoded variant arrays, expanded by the collector at deletion time - sweep dormant registry rows periodically so references lost through untriggered surfaces degrade to slow cleanup instead of leaking - park just-published revisions dormant, keep dormant rows dormant on re-cache, and batch the GC reference pre-check per run - heal rows re-referencing a just-deleted revision via reconciler-style resets after the deletion commits - share a per-URL image-loaded hook across DetailHero, ItemCard, SectionItemCard, GlobalSearch, and CollectionPosterCard - deduplicate Cache/CacheBytes finalization and drop unused VariantPaths plumbing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(catalog): cast reused timestamp parameter in revision upsert Postgres cannot deduce one type for $3 used both as a plain value and inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every publication. Cast both uses and cover the arm/park/track upserts with database-backed tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): address artwork revision review comments - keep a durable heal path: deletion marks deleted_at instead of removing the registry row, so a failed post-delete heal retries with backoff and broken references never park; trackers clear the marker on re-upload - never treat bare existence as an immutable-content match; backends without content verification rewrite the object - exercise revisioned cover keys in scanner/enrichment fakes, compare the tracked manifest exactly, and honor cancellation in the blocking test deleter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
323 lines
13 KiB
Go
323 lines
13 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const (
|
|
defaultArtworkGCGracePeriod = 24 * time.Hour
|
|
artworkTargetSeason = "season"
|
|
artworkTargetEpisode = "episode"
|
|
artworkImagePoster = "poster"
|
|
artworkImageBackdrop = "backdrop"
|
|
artworkImageLogo = "logo"
|
|
artworkImageStill = "still"
|
|
artworkPosterPathColumn = "poster_path"
|
|
artworkBackdropPathColumn = "backdrop_path"
|
|
artworkLogoPathColumn = "logo_path"
|
|
artworkPosterSourceColumn = "poster_source_path"
|
|
artworkBackdropSourceColumn = "backdrop_source_path"
|
|
artworkLogoSourceColumn = "logo_source_path"
|
|
artworkPosterThumbColumn = "poster_thumbhash"
|
|
artworkBackdropThumbColumn = "backdrop_thumbhash"
|
|
)
|
|
|
|
// ErrUnsupportedArtworkSelection reports a target/image-type combination that
|
|
// has no artwork column. Handlers should reject these before caching anything.
|
|
var ErrUnsupportedArtworkSelection = errors.New("catalog: unsupported artwork selection")
|
|
|
|
// ArtworkRevisionTracker registers every immutable artwork upload before its
|
|
// objects are written, allowing the collector to reclaim uploads that are
|
|
// never published. Confirmed live revisions remain dormant in the registry so
|
|
// database triggers can later reactivate them.
|
|
type ArtworkRevisionTracker struct {
|
|
pool *pgxpool.Pool
|
|
gracePeriod time.Duration
|
|
}
|
|
|
|
func NewArtworkRevisionTracker(pool *pgxpool.Pool) *ArtworkRevisionTracker {
|
|
if pool == nil {
|
|
return nil
|
|
}
|
|
return &ArtworkRevisionTracker{pool: pool, gracePeriod: defaultArtworkGCGracePeriod}
|
|
}
|
|
|
|
// TrackArtworkRevision records the exact object manifest for a revision before
|
|
// its upload starts. Image caching calls this before uploading, so it
|
|
// serializes with a collector that may already be deleting an older,
|
|
// currently-unreferenced copy. Revisions parked as referenced stay dormant: a
|
|
// re-cache of live artwork is not garbage, and displacement triggers re-arm
|
|
// the row if the reference later moves away.
|
|
func (t *ArtworkRevisionTracker) TrackArtworkRevision(ctx context.Context, originalPath, imageType string, objectKeys []string) error {
|
|
if t == nil || t.pool == nil {
|
|
return fmt.Errorf("catalog: artwork revision tracking is not configured")
|
|
}
|
|
originalPath = strings.TrimSpace(originalPath)
|
|
keys := compactArtworkObjectKeys(objectKeys)
|
|
if originalPath == "" || strings.Contains(originalPath, "://") || len(keys) == 0 {
|
|
return nil
|
|
}
|
|
notBefore := time.Now().Add(t.gracePeriod)
|
|
// deleted_at is cleared because this upsert precedes a re-upload of the
|
|
// exact manifest: the objects exist again once the cacher finishes.
|
|
_, err := t.pool.Exec(ctx, `
|
|
INSERT INTO artwork_revision_gc_candidates (
|
|
original_path, image_type, object_keys, not_before, next_attempt_at
|
|
) VALUES ($1, $2, $3, $4, $4)
|
|
ON CONFLICT (original_path) DO UPDATE SET
|
|
object_keys = EXCLUDED.object_keys,
|
|
image_type = CASE
|
|
WHEN artwork_revision_gc_candidates.image_type = '' THEN EXCLUDED.image_type
|
|
ELSE artwork_revision_gc_candidates.image_type
|
|
END,
|
|
not_before = CASE
|
|
WHEN artwork_revision_gc_candidates.next_attempt_at IS NULL THEN artwork_revision_gc_candidates.not_before
|
|
ELSE EXCLUDED.not_before
|
|
END,
|
|
next_attempt_at = CASE
|
|
WHEN artwork_revision_gc_candidates.next_attempt_at IS NULL THEN NULL
|
|
ELSE EXCLUDED.next_attempt_at
|
|
END,
|
|
deleted_at = NULL,
|
|
attempt_count = 0,
|
|
locked_at = NULL,
|
|
locked_by = '',
|
|
last_error = '',
|
|
updated_at = NOW()`, originalPath, strings.ToLower(strings.TrimSpace(imageType)), keys, notBefore)
|
|
if err != nil {
|
|
return fmt.Errorf("catalog: track artwork revision: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ArtworkSelection describes a manually selected, already-cached artwork
|
|
// revision. PublishArtworkSelection makes the database pointer and image lock
|
|
// visible atomically, then schedules the displaced revision for delayed cleanup.
|
|
type ArtworkSelection struct {
|
|
TargetType string
|
|
TargetContentID string
|
|
ParentContentID string
|
|
ImageType string
|
|
StoredPath string
|
|
SourcePath string
|
|
Thumbhash string
|
|
LockField int
|
|
}
|
|
|
|
// ValidateArtworkSelectionTarget reports whether a target/image-type pair maps
|
|
// to a real artwork column, so handlers can reject unsupported requests before
|
|
// downloading or uploading anything.
|
|
func ValidateArtworkSelectionTarget(targetType, imageType string) error {
|
|
_, _, _, _, _, err := artworkTargetColumns(
|
|
strings.ToLower(strings.TrimSpace(targetType)),
|
|
strings.ToLower(strings.TrimSpace(imageType)),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// PublishArtworkSelection atomically changes the selected artwork, records its
|
|
// source, locks automatic image refreshes, and queues the old immutable revision
|
|
// for reference-aware garbage collection after a grace period.
|
|
func (s *DetailService) PublishArtworkSelection(ctx context.Context, selection ArtworkSelection) error {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return fmt.Errorf("catalog: artwork persistence is not configured")
|
|
}
|
|
selection.TargetType = strings.ToLower(strings.TrimSpace(selection.TargetType))
|
|
selection.ImageType = strings.ToLower(strings.TrimSpace(selection.ImageType))
|
|
selection.TargetContentID = strings.TrimSpace(selection.TargetContentID)
|
|
selection.ParentContentID = strings.TrimSpace(selection.ParentContentID)
|
|
selection.StoredPath = strings.TrimSpace(selection.StoredPath)
|
|
if selection.TargetContentID == "" || selection.StoredPath == "" {
|
|
return fmt.Errorf("catalog: artwork target and stored path are required")
|
|
}
|
|
|
|
table, pathColumn, sourceColumn, thumbhashColumn, notFound, err := artworkTargetColumns(selection.TargetType, selection.ImageType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.itemRepo.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("catalog: begin artwork publication: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var previousPath string
|
|
selectSQL := fmt.Sprintf("SELECT COALESCE(%s, '') FROM %s WHERE content_id = $1 FOR UPDATE", pathColumn, table)
|
|
if err := tx.QueryRow(ctx, selectSQL, selection.TargetContentID).Scan(&previousPath); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return notFound
|
|
}
|
|
return fmt.Errorf("catalog: lock artwork target: %w", err)
|
|
}
|
|
|
|
setThumbhash := ""
|
|
args := []any{selection.StoredPath, selection.SourcePath, selection.TargetContentID}
|
|
if thumbhashColumn != "" {
|
|
setThumbhash = fmt.Sprintf(", %s = $4", thumbhashColumn)
|
|
args = append(args, selection.Thumbhash)
|
|
}
|
|
updateSQL := fmt.Sprintf(`
|
|
UPDATE %s
|
|
SET %s = $1, %s = $2%s, updated_at = NOW()
|
|
WHERE content_id = $3`, table, pathColumn, sourceColumn, setThumbhash)
|
|
if _, err := tx.Exec(ctx, updateSQL, args...); err != nil {
|
|
return fmt.Errorf("catalog: publish artwork pointer: %w", err)
|
|
}
|
|
|
|
lockContentID := selection.TargetContentID
|
|
if selection.TargetType == artworkTargetSeason || selection.TargetType == artworkTargetEpisode {
|
|
lockContentID = selection.ParentContentID
|
|
}
|
|
if lockContentID == "" {
|
|
return fmt.Errorf("catalog: parent content ID is required for %s artwork", selection.TargetType)
|
|
}
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE media_items
|
|
SET locked_fields = CASE
|
|
WHEN $2 = ANY(COALESCE(locked_fields, '{}'::integer[])) THEN COALESCE(locked_fields, '{}'::integer[])
|
|
ELSE array_append(COALESCE(locked_fields, '{}'::integer[]), $2)
|
|
END,
|
|
updated_at = NOW()
|
|
WHERE content_id = $1`, lockContentID, selection.LockField)
|
|
if err != nil {
|
|
return fmt.Errorf("catalog: lock manual artwork: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrItemNotFound
|
|
}
|
|
// The selected revision is referenced by construction once this transaction
|
|
// commits, so park it dormant immediately: no first-pass verification cycle
|
|
// is needed, and database triggers re-arm it if a later writer displaces it.
|
|
if err := parkArtworkRevision(ctx, tx, selection.StoredPath, selection.ImageType, time.Now().Add(defaultArtworkGCGracePeriod)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if previousPath != "" && previousPath != selection.StoredPath {
|
|
if err := queueArtworkRevisionGC(ctx, tx, previousPath, selection.ImageType, time.Now().Add(defaultArtworkGCGracePeriod)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("catalog: commit artwork publication: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// QueueArtworkRevisionGC schedules an unreferenced cached revision for cleanup.
|
|
// It is used to reclaim an upload when publication fails after object storage
|
|
// succeeded. Cleanup still verifies database references before deleting.
|
|
func (s *DetailService) QueueArtworkRevisionGC(ctx context.Context, originalPath, imageType string, notBefore time.Time) error {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return fmt.Errorf("catalog: artwork persistence is not configured")
|
|
}
|
|
return queueArtworkRevisionGC(ctx, s.itemRepo.pool, originalPath, imageType, notBefore)
|
|
}
|
|
|
|
type artworkGCExecer interface {
|
|
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
|
}
|
|
|
|
// queueArtworkRevisionGC arms a revision for verification after notBefore. The
|
|
// stored manifest (when one was tracked pre-upload) is kept; rows without one
|
|
// carry the image type so the collector can expand the object keys itself.
|
|
func queueArtworkRevisionGC(ctx context.Context, db artworkGCExecer, originalPath, imageType string, notBefore time.Time) error {
|
|
return upsertArtworkRevision(ctx, db, originalPath, imageType, notBefore, false)
|
|
}
|
|
|
|
// parkArtworkRevision registers a revision as referenced and dormant. Only a
|
|
// displacement trigger or the collector's dormant sweep re-arms it.
|
|
func parkArtworkRevision(ctx context.Context, db artworkGCExecer, originalPath, imageType string, notBefore time.Time) error {
|
|
return upsertArtworkRevision(ctx, db, originalPath, imageType, notBefore, true)
|
|
}
|
|
|
|
func upsertArtworkRevision(
|
|
ctx context.Context,
|
|
db artworkGCExecer,
|
|
originalPath string,
|
|
imageType string,
|
|
notBefore time.Time,
|
|
dormant bool,
|
|
) error {
|
|
originalPath = strings.TrimSpace(originalPath)
|
|
imageType = strings.ToLower(strings.TrimSpace(imageType))
|
|
if originalPath == "" || strings.Contains(originalPath, "://") || imageType == "" {
|
|
return nil
|
|
}
|
|
_, err := db.Exec(ctx, `
|
|
INSERT INTO artwork_revision_gc_candidates (
|
|
original_path, image_type, object_keys, not_before, next_attempt_at
|
|
) VALUES ($1, $2, '{}', $3::timestamptz, CASE WHEN $4 THEN NULL ELSE $3::timestamptz END)
|
|
ON CONFLICT (original_path) DO UPDATE SET
|
|
image_type = CASE
|
|
WHEN artwork_revision_gc_candidates.image_type = '' THEN EXCLUDED.image_type
|
|
ELSE artwork_revision_gc_candidates.image_type
|
|
END,
|
|
not_before = EXCLUDED.not_before,
|
|
next_attempt_at = EXCLUDED.next_attempt_at,
|
|
attempt_count = 0,
|
|
locked_at = NULL,
|
|
locked_by = '',
|
|
last_error = '',
|
|
updated_at = NOW()`, originalPath, imageType, notBefore, dormant)
|
|
if err != nil {
|
|
return fmt.Errorf("catalog: queue artwork revision cleanup: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compactArtworkObjectKeys(keys []string) []string {
|
|
seen := make(map[string]struct{}, len(keys))
|
|
result := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || strings.Contains(key, "://") {
|
|
continue
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
result = append(result, key)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// artworkTargetColumns maps a selection target to its artwork columns. Season
|
|
// and episode rows have dedicated tables; every other catalog item type
|
|
// (movie, series, audiobook, ebook, manga, ...) stores artwork on its
|
|
// media_items row.
|
|
func artworkTargetColumns(targetType, imageType string) (table, pathColumn, sourceColumn, thumbhashColumn string, notFound error, err error) {
|
|
switch targetType {
|
|
case artworkTargetSeason:
|
|
if imageType == artworkImagePoster {
|
|
return "seasons", artworkPosterPathColumn, artworkPosterSourceColumn, artworkPosterThumbColumn, ErrSeasonNotFound, nil
|
|
}
|
|
case artworkTargetEpisode:
|
|
if imageType == artworkImageStill {
|
|
return "episodes", "still_path", "still_source_path", "still_thumbhash", ErrEpisodeNotFound, nil
|
|
}
|
|
default:
|
|
table = "media_items"
|
|
notFound = ErrItemNotFound
|
|
switch imageType {
|
|
case artworkImagePoster:
|
|
return table, artworkPosterPathColumn, artworkPosterSourceColumn, artworkPosterThumbColumn, notFound, nil
|
|
case artworkImageBackdrop:
|
|
return table, artworkBackdropPathColumn, artworkBackdropSourceColumn, artworkBackdropThumbColumn, notFound, nil
|
|
case artworkImageLogo:
|
|
return table, artworkLogoPathColumn, artworkLogoSourceColumn, "", notFound, nil
|
|
}
|
|
}
|
|
return "", "", "", "", nil, fmt.Errorf("%w: %s does not accept %q images", ErrUnsupportedArtworkSelection, targetType, imageType)
|
|
}
|