navidrome/model/artwork.go
Deluan 8badec2d62 refactor(artwork): drop what the removed retry paths left behind
Deleting the absent-retry jobs and the startup backfill orphaned more
than the code that went with them.

`Worker.agents` lost its only reader when Backfill went.
`RadioRepository.GetAllIDs` existed solely for that backfill's bulk
enumeration, as its own comment said. `CountAbsent` became a second
query for a number `artwork status` already had: only two paths write
item_artwork, and only the absent one leaves `source` empty, so
`hash = ''` and `source = ''` select the same rows. The absent count now
comes from the source breakdown collected in the same loop, four queries
cheaper.

Two defects surfaced while pulling on that thread. `reprocess --all`
returned on an empty match set before recording the fingerprint, so the
command the startup warning names could not silence it. And the "did
this run apply the config" flag was derived in the CLI from its own
globals, which made `--kind ar --kind al --kind pl --kind rd` — work
identical to `--all` — leave the fingerprint stale. It is now derived
inside reprocessArtwork from the kinds and sources that drive the
queries, so a filter added later has to pass through it.

settlesAbsentOnGiveUp decided a worker persistence policy by testing
membership in a CLI-facing kind list. It now states the policy directly.
Its rationale was wrong too: media files already get an absent row on
the definitive path, and the reason to skip them on give-up is that a
track with no row still falls back to disc or album art.

CheckConfigFingerprint wrote to the database on one of three branches,
so it is ReconcileConfigFingerprint now.
2026-08-30 20:03:10 -04:00

175 lines
7.9 KiB
Go

package model
import "time"
// Artwork is one unique image, identified by the XXH3-64 hash of its bytes.
type Artwork struct {
Hash string `structs:"hash"`
Mime string `structs:"mime"`
Width int `structs:"width"`
Height int `structs:"height"`
SizeBytes int64 `structs:"size_bytes"`
BlurHash string `structs:"blur_hash"`
ThumbHash string `structs:"thumb_hash"`
// DominantColor is "#rrggbb": a flat placeholder clients can paint before any decode.
DominantColor string `structs:"dominant_color"`
CreatedAt time.Time `structs:"created_at"`
}
const ImageTypePrimary = "primary"
// ItemImage is per-entity artwork state hydrated at query time; never persisted.
type ItemImage struct {
ImageHash string `structs:"-" json:"imageHash,omitempty"`
ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"`
// BlurHash is Jellyfin's; its mappers read this field directly, so it stays off native JSON.
BlurHash string `structs:"-" json:"-"`
ThumbHash string `structs:"-" json:"thumbHash,omitempty"`
// DominantColor is the only placeholder needing no decode, so it can paint on the first frame.
DominantColor string `structs:"-" json:"dominantColor,omitempty"`
// A thumbhash's own aspect is quantised, so clients need these to shape the placeholder exactly.
ImageWidth int `structs:"-" json:"imageWidth,omitempty"`
ImageHeight int `structs:"-" json:"imageHeight,omitempty"`
}
// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown.
func (i ItemImage) AspectRatio() *float64 {
if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 {
return nil
}
return new(float64(i.ImageWidth) / float64(i.ImageHeight))
}
// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent.
type ItemArtwork struct {
ItemKind string `structs:"item_kind"`
ItemID string `structs:"item_id"`
ImageType string `structs:"image_type"`
Hash string `structs:"hash"`
Source string `structs:"source"`
// SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise.
SourcePath string `structs:"source_path"`
// RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath.
RefMtime int64 `structs:"ref_mtime"`
// Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt
// that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace.
Trace string `structs:"trace"`
LastFailure string `structs:"last_failure"`
// Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL.
AttemptedAt time.Time `structs:"attempted_at"`
UpdatedAt time.Time `structs:"updated_at"`
}
// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork).
type ItemArtworkInfo struct {
ItemID string
Hash string
BlurHash string
ThumbHash string
DominantColor string
Width int
Height int
}
// Absent reports a known-absent artwork state (resolved, no image).
func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" }
// Image projects the hydration entry onto the entity-facing struct.
func (i ItemArtworkInfo) Image() ItemImage {
return ItemImage{
ImageHash: i.Hash,
ImageAbsent: i.Absent(),
BlurHash: i.BlurHash,
ThumbHash: i.ThumbHash,
DominantColor: i.DominantColor,
ImageWidth: i.Width,
ImageHeight: i.Height,
}
}
type ArtworkQueueItem struct {
ItemKind string `structs:"item_kind"`
ItemID string `structs:"item_id"`
ImageType string `structs:"image_type"`
Priority int `structs:"priority"`
Attempts int `structs:"attempts"`
// RetryAt is the earliest time the drain may take this row, not when it will run.
RetryAt time.Time `structs:"retry_at"`
EnqueuedAt time.Time `structs:"enqueued_at"`
// Trace is why the last attempt failed. Only Get reads it; the drain projects it away.
Trace string `structs:"trace"`
}
// Queue priorities: higher drains first.
const (
ArtworkPriorityRecheck = 0
// ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues
// it today; it stays named so a row still carrying it can be reported and cancelled.
ArtworkPriorityBackfill = 10
ArtworkPriorityScan = 50
ArtworkPriorityBump = 100
)
// Delete* takes the rows to remove; Purge* finds them itself and reports how many went.
type ArtworkRepository interface {
GetImage(hash string) (*Artwork, error)
PutImage(a *Artwork) error
// PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff.
PurgeOrphans(createdBefore time.Time) (int64, error)
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
PutItemArtwork(ia *ItemArtwork) error
// PutLastFailure records the trace of the attempt that exhausted the retry budget.
PutLastFailure(kind Kind, id, imageType, trace string) error
DeleteForItems(kind Kind, ids []string) error
// GetInfoForItems hydrates a page in one batched query.
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
// GetMimeByHash returns hash -> current mime for every stored artwork.
GetMimeByHash() (map[string]string, error)
// PurgeDanglingItems removes state rows whose entity no longer exists.
PurgeDanglingItems() (int64, error)
}
type ArtworkQueueRepository interface {
// Get returns the pending row for an item, or ErrNotFound when it is not queued.
Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error)
// Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
Enqueue(items ...ArtworkQueueItem) error
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
// request-triggered read-through never resets a failed resolution's backoff.
EnqueuePreservingBackoff(items ...ArtworkQueueItem) error
// EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority.
EnqueueAllMissing(kind Kind, priority int) (int64, error)
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
EnqueueIfMissing(items ...ArtworkQueueItem) error
// CountBySource reports how many items of a kind currently resolve from the given sources.
// An empty sources slice means every source; "" matches absent state.
CountBySource(kind Kind, sources []string) (int64, error)
// SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included.
SourcesInUse(kind Kind) ([]string, error)
// EnqueueBySource inserts queue rows for items of a kind whose current source matches.
// It does not clear existing artwork state: the current image stays until it is replaced.
EnqueueBySource(kind Kind, sources []string, priority int) (int64, error)
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
// Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
// seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
// an empty filter means every one.
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
}
type ArtworkQueueStat struct {
ItemKind string
Priority int
Count int64
}