2026-07-22 10:01:47 -04:00
|
|
|
|
package artwork
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-25 11:47:40 -04:00
|
|
|
|
"bytes"
|
2026-07-22 10:01:47 -04:00
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"math/rand/v2"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
2026-07-22 19:52:59 -04:00
|
|
|
|
"github.com/navidrome/navidrome/core/agents"
|
2026-07-22 18:02:54 -04:00
|
|
|
|
"github.com/navidrome/navidrome/core/auth"
|
2026-07-22 19:52:59 -04:00
|
|
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
2026-07-22 10:01:47 -04:00
|
|
|
|
"github.com/navidrome/navidrome/log"
|
|
|
|
|
|
"github.com/navidrome/navidrome/model"
|
2026-07-22 20:53:22 -04:00
|
|
|
|
"github.com/navidrome/navidrome/server/events"
|
2026-07-22 22:45:28 -04:00
|
|
|
|
"github.com/navidrome/navidrome/utils/cache"
|
2026-07-22 10:01:47 -04:00
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
workerPollInterval = 5 * time.Second
|
2026-07-24 17:27:40 -04:00
|
|
|
|
backoffBase = 5 * time.Second
|
|
|
|
|
|
// giveUpAfter bounds the retry budget from enqueue: past it the worker stops retrying and
|
|
|
|
|
|
// hands the item to the periodic stale-absent recheck (settling absent on a bare failure).
|
|
|
|
|
|
giveUpAfter = 12 * time.Hour
|
|
|
|
|
|
breakerThreshold = 5
|
|
|
|
|
|
breakerProbeAfter = time.Minute
|
2026-07-22 10:01:47 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
|
|
|
|
|
|
|
2026-07-22 19:52:59 -04:00
|
|
|
|
// extGate is one agent's rate limiter + circuit breaker; each external agent gets its
|
|
|
|
|
|
// own so a provider whose API or CDN is down backs off in isolation from the others.
|
|
|
|
|
|
type extGate struct {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
limiter *rate.Limiter
|
|
|
|
|
|
breaker *breaker
|
2026-07-22 19:52:59 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 13:32:07 -04:00
|
|
|
|
// drainPool drains one class of work with its own slot budget, so a kind whose resolution
|
|
|
|
|
|
// blocks cannot occupy slots another kind needs.
|
|
|
|
|
|
type drainPool struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
kinds []string
|
|
|
|
|
|
concurrency int
|
|
|
|
|
|
wake chan struct{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 19:52:59 -04:00
|
|
|
|
// Worker drains the artwork queue through processItem: each external agent is rate-limited
|
|
|
|
|
|
// and circuit-broken independently, and prune is serialized against in-flight acquisitions
|
|
|
|
|
|
// via pruneMu.
|
|
|
|
|
|
type Worker struct {
|
|
|
|
|
|
deps workerDeps
|
2026-07-22 20:53:22 -04:00
|
|
|
|
broker events.Broker
|
2026-07-22 10:01:47 -04:00
|
|
|
|
pruneMu sync.RWMutex
|
2026-07-25 13:32:07 -04:00
|
|
|
|
pools []*drainPool
|
2026-07-22 10:01:47 -04:00
|
|
|
|
runCtx context.Context
|
|
|
|
|
|
|
2026-07-22 19:52:59 -04:00
|
|
|
|
gatesMu sync.Mutex
|
|
|
|
|
|
gates map[string]*extGate
|
|
|
|
|
|
|
2026-07-22 10:01:47 -04:00
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
inFlight map[string]struct{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 22:45:28 -04:00
|
|
|
|
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
w := &Worker{
|
2026-07-22 22:45:28 -04:00
|
|
|
|
deps: workerDeps{ds: ds, store: store, agents: ag, ffmpeg: ffmpeg, cache: imgCache},
|
2026-07-22 20:53:22 -04:00
|
|
|
|
broker: broker,
|
2026-07-25 13:32:07 -04:00
|
|
|
|
pools: newDrainPools(),
|
2026-07-22 10:01:47 -04:00
|
|
|
|
runCtx: context.Background(),
|
2026-07-22 19:52:59 -04:00
|
|
|
|
gates: map[string]*extGate{},
|
2026-07-22 10:01:47 -04:00
|
|
|
|
inFlight: map[string]struct{}{},
|
|
|
|
|
|
}
|
2026-07-22 19:52:59 -04:00
|
|
|
|
w.deps.gate = w.gate
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return w
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 13:32:07 -04:00
|
|
|
|
// newDrainPools splits the drain by what bounds it: gate() waits for its rate-limit permit
|
|
|
|
|
|
// while holding a slot, so a sleeping lookup would otherwise crowd out a cover sitting on disk.
|
|
|
|
|
|
func newDrainPools() []*drainPool {
|
|
|
|
|
|
budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive
|
|
|
|
|
|
local := min(max(1, conf.Server.ArtworkWorkerConcurrency), budget-1)
|
|
|
|
|
|
// More external slots than the rate allows would only sleep in the limiter.
|
|
|
|
|
|
external := min(max(2, 2*conf.Server.ArtworkExternalMaxRPS), budget-local)
|
|
|
|
|
|
return []*drainPool{
|
|
|
|
|
|
{name: "local", kinds: localDrainKinds, concurrency: local, wake: make(chan struct{}, 1)},
|
|
|
|
|
|
{name: "external", kinds: externalDrainKinds, concurrency: external, wake: make(chan struct{}, 1)},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Kind is a proxy for cost: an album whose chain reaches "external" still costs a local slot,
|
|
|
|
|
|
// but only the few with no local art do.
|
|
|
|
|
|
var (
|
|
|
|
|
|
externalDrainKinds = []string{model.KindArtistArtwork.Prefix()}
|
|
|
|
|
|
localDrainKinds = []string{
|
|
|
|
|
|
model.KindAlbumArtwork.Prefix(),
|
|
|
|
|
|
model.KindPlaylistArtwork.Prefix(),
|
|
|
|
|
|
model.KindRadioArtwork.Prefix(),
|
|
|
|
|
|
model.KindMediaFileArtwork.Prefix(),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 10:01:47 -04:00
|
|
|
|
// Run blocks draining the queue until ctx is cancelled. It exits cleanly with no
|
|
|
|
|
|
// leaked goroutines: each drain waits for its batch before the loop can return.
|
|
|
|
|
|
func (w *Worker) Run(ctx context.Context) error {
|
|
|
|
|
|
w.runCtx = ctx
|
2026-07-25 13:32:07 -04:00
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
for _, p := range w.pools {
|
|
|
|
|
|
wg.Go(func() { w.runPool(ctx, p) })
|
|
|
|
|
|
}
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// runPool drains one pool's kinds until ctx is cancelled.
|
|
|
|
|
|
func (w *Worker) runPool(ctx context.Context, p *drainPool) {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
ticker := time.NewTicker(workerPollInterval)
|
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
for {
|
2026-07-25 13:32:07 -04:00
|
|
|
|
n, err := w.drain(ctx, p.concurrency, p.kinds...)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
if err != nil && ctx.Err() == nil {
|
2026-07-25 13:32:07 -04:00
|
|
|
|
log.Warn(ctx, "artwork: worker drain failed", "pool", p.name, err)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
if ctx.Err() != nil {
|
2026-07-25 13:32:07 -04:00
|
|
|
|
return
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
if n > 0 {
|
2026-07-25 13:32:07 -04:00
|
|
|
|
continue // keep draining while this pool has ready work
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
2026-07-25 13:32:07 -04:00
|
|
|
|
return
|
2026-07-22 10:01:47 -04:00
|
|
|
|
case <-ticker.C:
|
2026-07-25 13:32:07 -04:00
|
|
|
|
case <-p.wake:
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bump enqueues an item at the highest priority and wakes the drain loop. It is
|
|
|
|
|
|
// non-blocking: a wake already pending is enough.
|
|
|
|
|
|
func (w *Worker) Bump(kind, id string) {
|
|
|
|
|
|
item := model.ArtworkQueueItem{
|
|
|
|
|
|
ItemKind: kind,
|
|
|
|
|
|
ItemID: id,
|
|
|
|
|
|
ImageType: model.ImageTypePrimary,
|
|
|
|
|
|
Priority: model.ArtworkPriorityBump,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := w.deps.ds.ArtworkQueue(context.Background()).Enqueue(item); err != nil {
|
|
|
|
|
|
log.Warn("artwork: could not bump queue item", "kind", kind, "id", id, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 13:32:07 -04:00
|
|
|
|
// Waking all beats routing by kind: a spurious wake costs one empty dequeue, while an
|
|
|
|
|
|
// unrouted kind would never wake at all.
|
|
|
|
|
|
for _, p := range w.pools {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case p.wake <- struct{}{}:
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RunPrune runs Prune under the worker's write lock, so no acquisition can place
|
|
|
|
|
|
// a file while orphans are being reclaimed. This is the only sanctioned prune path.
|
|
|
|
|
|
func (w *Worker) RunPrune(ctx context.Context) error {
|
|
|
|
|
|
w.pruneMu.Lock()
|
|
|
|
|
|
defer w.pruneMu.Unlock()
|
|
|
|
|
|
return Prune(ctx, w.deps.ds, w.deps.store)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 13:32:07 -04:00
|
|
|
|
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
|
2026-07-25 11:48:14 -04:00
|
|
|
|
// Dequeue well past the worker pool so a slow item (an external lookup burning its
|
|
|
|
|
|
// timeout) never idles the other slots: the pool stays fed until the batch runs out.
|
|
|
|
|
|
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
2026-07-25 13:32:07 -04:00
|
|
|
|
batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
items := w.claim(batch)
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
}
|
2026-07-25 11:47:40 -04:00
|
|
|
|
// Resolved only once there is work, and per drain rather than per item: the worker needs an
|
|
|
|
|
|
// admin identity for private playlists, and can start before any admin exists.
|
|
|
|
|
|
ctx = auth.WithAdminUser(ctx, w.deps.ds)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
sem := make(chan struct{}, concurrency)
|
|
|
|
|
|
var wg sync.WaitGroup
|
2026-07-23 09:37:07 -04:00
|
|
|
|
var refreshMu sync.Mutex
|
|
|
|
|
|
var refresh []model.ArtworkQueueItem
|
2026-07-25 14:39:44 -04:00
|
|
|
|
for i, item := range items {
|
2026-07-25 11:48:14 -04:00
|
|
|
|
select {
|
|
|
|
|
|
case sem <- struct{}{}:
|
|
|
|
|
|
case <-ctx.Done():
|
2026-07-25 14:39:44 -04:00
|
|
|
|
// claim() reserved the whole batch; anything not dispatched has to go back, or it
|
|
|
|
|
|
// stays in flight forever and no later drain can pick it up.
|
|
|
|
|
|
for _, undispatched := range items[i:] {
|
|
|
|
|
|
w.release(undispatched)
|
|
|
|
|
|
}
|
2026-07-25 11:48:14 -04:00
|
|
|
|
wg.Wait()
|
|
|
|
|
|
return len(items), nil
|
|
|
|
|
|
}
|
2026-07-22 10:01:47 -04:00
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func(it model.ArtworkQueueItem) {
|
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
defer func() { <-sem }()
|
|
|
|
|
|
defer w.release(it)
|
2026-07-25 11:47:40 -04:00
|
|
|
|
out, got := w.process(ctx, it)
|
2026-07-23 09:37:07 -04:00
|
|
|
|
// Refresh clients on any visible state change: found/foundStale (new art) and absent
|
|
|
|
|
|
// (removed art — clients must drop a previously-served immutable cover). foundStale
|
|
|
|
|
|
// also wrote a served state row.
|
|
|
|
|
|
if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent {
|
|
|
|
|
|
refreshMu.Lock()
|
|
|
|
|
|
refresh = append(refresh, it)
|
|
|
|
|
|
refreshMu.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
// Precache only actual images. Post-outcome only: the queue row was already settled
|
|
|
|
|
|
// by process, so warming the resize cache here can never block or alter queue ops.
|
2026-07-25 11:47:40 -04:00
|
|
|
|
if got != nil {
|
|
|
|
|
|
w.precache(ctx, got)
|
2026-07-22 20:53:22 -04:00
|
|
|
|
}
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
wg.Wait()
|
2026-07-23 09:37:07 -04:00
|
|
|
|
w.broadcastRefresh(ctx, refresh)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return len(items), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(artwork): thread model.Kind through the artwork API
Entity-level artwork queries now take a typed model.Kind instead of a bare
prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems,
EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh
convert to the prefix string only at the two real boundaries: the SQL
item_kind column (kind.Prefix() inside each repo) and external string inputs
(a new model.ParseKind for the nativeapi URL param, which also validates it).
The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the
kind→resource / kind→table lookup maps now use the Kind vars directly. The
queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate
on a dequeued item's raw ItemKind column, which stays a string field, always
populated via kind.Prefix().
Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0);
behavior is unchanged.
2026-07-24 16:58:23 -04:00
|
|
|
|
// artworkKindToResource maps an artwork kind to the UI resource name carried in the refresh
|
|
|
|
|
|
// event; note media_file maps to "song", so this can't derive from Kind.String().
|
|
|
|
|
|
var artworkKindToResource = map[model.Kind]string{
|
|
|
|
|
|
model.KindAlbumArtwork: "album",
|
|
|
|
|
|
model.KindArtistArtwork: "artist",
|
|
|
|
|
|
model.KindPlaylistArtwork: "playlist",
|
|
|
|
|
|
model.KindRadioArtwork: "radio",
|
|
|
|
|
|
model.KindMediaFileArtwork: "song",
|
2026-07-22 20:53:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// broadcastRefresh emits one coalesced RefreshResource for the batch's newly-acquired
|
|
|
|
|
|
// artwork, so connected UIs re-fetch the affected records (and pick up the new coverArt id).
|
|
|
|
|
|
func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) {
|
|
|
|
|
|
if len(found) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
event := &events.RefreshResource{}
|
|
|
|
|
|
byResource := map[string][]string{}
|
|
|
|
|
|
for _, it := range found {
|
refactor(artwork): thread model.Kind through the artwork API
Entity-level artwork queries now take a typed model.Kind instead of a bare
prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems,
EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh
convert to the prefix string only at the two real boundaries: the SQL
item_kind column (kind.Prefix() inside each repo) and external string inputs
(a new model.ParseKind for the nativeapi URL param, which also validates it).
The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the
kind→resource / kind→table lookup maps now use the Kind vars directly. The
queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate
on a dequeued item's raw ItemKind column, which stays a string field, always
populated via kind.Prefix().
Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0);
behavior is unchanged.
2026-07-24 16:58:23 -04:00
|
|
|
|
kind, _ := model.ParseKind(it.ItemKind)
|
|
|
|
|
|
if res, ok := artworkKindToResource[kind]; ok {
|
2026-07-22 20:53:22 -04:00
|
|
|
|
byResource[res] = append(byResource[res], it.ItemID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(byResource) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
for res, ids := range byResource {
|
|
|
|
|
|
event = event.With(res, ids...)
|
|
|
|
|
|
}
|
|
|
|
|
|
w.broker.SendBroadcastMessage(ctx, event)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 11:47:40 -04:00
|
|
|
|
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
if item.ImageType == "" {
|
|
|
|
|
|
item.ImageType = model.ImageTypePrimary
|
|
|
|
|
|
}
|
|
|
|
|
|
w.pruneMu.RLock()
|
2026-07-25 11:47:40 -04:00
|
|
|
|
out, got := processItem(ctx, &w.deps, item)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
w.pruneMu.RUnlock()
|
|
|
|
|
|
|
|
|
|
|
|
queue := w.deps.ds.ArtworkQueue(ctx)
|
|
|
|
|
|
switch out {
|
|
|
|
|
|
case outcomeFound, outcomeAbsent:
|
2026-07-22 11:57:40 -04:00
|
|
|
|
// DeleteIfUnchanged, not Delete: a scan that re-enqueued this row mid-flight reset
|
|
|
|
|
|
// its retry_at, so the row survives here and the next drain re-resolves it.
|
|
|
|
|
|
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
|
|
|
|
}
|
2026-07-22 12:39:15 -04:00
|
|
|
|
case outcomeFoundStale, outcomeFailed:
|
2026-07-22 10:01:47 -04:00
|
|
|
|
retryAt := time.Now().Add(backoff(item.Attempts))
|
2026-07-24 17:27:40 -04:00
|
|
|
|
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
|
|
|
|
|
// MarkFailedIfUnchanged, not MarkFailed: a scan that re-enqueued this row mid-flight reset
|
|
|
|
|
|
// retry_at, so stale backoff must not stomp its fresh, immediate eligibility.
|
|
|
|
|
|
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
|
|
|
|
|
|
log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
// Retry budget exhausted: stop retrying. A bare failure settles absent so the stale-absent
|
|
|
|
|
|
// sweep (and a page view) can still recover it; a stale-found keeps its already-served art.
|
2026-07-25 10:08:29 -04:00
|
|
|
|
// Art we are already serving is kept too: exhaustion means the source stayed unreachable,
|
|
|
|
|
|
// not that the entity lost its cover.
|
|
|
|
|
|
if out == outcomeFailed && !w.hasResolvedArtwork(ctx, item) {
|
2026-07-24 17:27:40 -04:00
|
|
|
|
writeAbsent(ctx, w.deps.ds.Artwork(ctx), item)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
|
|
|
|
|
log.Warn(ctx, "artwork: could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-25 11:47:40 -04:00
|
|
|
|
return out, got
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 10:08:29 -04:00
|
|
|
|
// hasResolvedArtwork reports whether the item already has a hash-bearing state row.
|
|
|
|
|
|
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
|
|
|
|
|
|
kind, ok := model.ParseKind(item.ItemKind)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
ia, err := w.deps.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
|
|
|
|
|
return err == nil && ia.Hash != ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 11:47:40 -04:00
|
|
|
|
// precache warms the resize cache at the UI cover size from the bytes just acquired, so the
|
|
|
|
|
|
// first UI request is a cache hit without re-reading the rows or the file. Skipped when
|
|
|
|
|
|
// disabled; failures are debug-only.
|
|
|
|
|
|
func (w *Worker) precache(ctx context.Context, got *acquired) {
|
2026-07-22 22:45:28 -04:00
|
|
|
|
if !conf.Server.EnableArtworkPrecache || w.deps.cache == nil || w.deps.cache.Disabled(ctx) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 11:47:40 -04:00
|
|
|
|
// Same key as the serving path (hash/size/square); only the source of the bytes differs.
|
|
|
|
|
|
item := &resizedItem{
|
|
|
|
|
|
hash: got.ia.Hash,
|
|
|
|
|
|
size: conf.Server.UICoverArtSize,
|
|
|
|
|
|
lastUpdate: got.ia.UpdatedAt,
|
|
|
|
|
|
ffmpeg: w.deps.ffmpeg,
|
|
|
|
|
|
open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil },
|
2026-07-22 22:45:28 -04:00
|
|
|
|
}
|
2026-07-25 11:47:40 -04:00
|
|
|
|
stream, err := w.deps.cache.Get(ctx, item)
|
2026-07-22 22:45:28 -04:00
|
|
|
|
if err != nil {
|
2026-07-25 11:47:40 -04:00
|
|
|
|
log.Debug(ctx, "artwork: precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err)
|
2026-07-22 22:45:28 -04:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
_, _ = io.Copy(io.Discard, stream)
|
|
|
|
|
|
_ = stream.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 11:57:40 -04:00
|
|
|
|
// claim reserves items not already in flight, so a row appearing twice within a single
|
|
|
|
|
|
// batch is processed once.
|
2026-07-22 10:01:47 -04:00
|
|
|
|
func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem {
|
|
|
|
|
|
w.mu.Lock()
|
|
|
|
|
|
defer w.mu.Unlock()
|
|
|
|
|
|
var out []model.ArtworkQueueItem
|
|
|
|
|
|
for _, it := range batch {
|
|
|
|
|
|
k := queueKey(it)
|
|
|
|
|
|
if _, busy := w.inFlight[k]; busy {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
w.inFlight[k] = struct{}{}
|
|
|
|
|
|
out = append(out, it)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (w *Worker) release(it model.ArtworkQueueItem) {
|
|
|
|
|
|
w.mu.Lock()
|
|
|
|
|
|
delete(w.inFlight, queueKey(it))
|
|
|
|
|
|
w.mu.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func queueKey(it model.ArtworkQueueItem) string {
|
|
|
|
|
|
return it.ItemKind + "|" + it.ItemID + "|" + it.ImageType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 19:52:59 -04:00
|
|
|
|
// gate wraps a named external step with that agent's own rate limiter and circuit
|
|
|
|
|
|
// breaker, matching gateFunc so it can be injected via workerDeps.gate.
|
|
|
|
|
|
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
|
|
|
|
g := w.gateFor(name)
|
|
|
|
|
|
if !g.breaker.allow() {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return nil, "", errBreakerOpen
|
|
|
|
|
|
}
|
2026-07-22 19:52:59 -04:00
|
|
|
|
if err := g.limiter.Wait(w.runCtx); err != nil {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
r, path, err := f()
|
2026-07-22 19:52:59 -04:00
|
|
|
|
g.breaker.record(err)
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return r, path, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 19:52:59 -04:00
|
|
|
|
// gateFor lazily creates the per-name gate on first use, each with its own limiter at
|
|
|
|
|
|
// ArtworkExternalMaxRPS and its own breaker.
|
|
|
|
|
|
func (w *Worker) gateFor(name string) *extGate {
|
|
|
|
|
|
w.gatesMu.Lock()
|
|
|
|
|
|
defer w.gatesMu.Unlock()
|
|
|
|
|
|
if g, ok := w.gates[name]; ok {
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
rps := conf.Server.ArtworkExternalMaxRPS
|
|
|
|
|
|
limit := rate.Inf
|
|
|
|
|
|
if rps > 0 {
|
|
|
|
|
|
limit = rate.Limit(rps)
|
|
|
|
|
|
}
|
|
|
|
|
|
g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()}
|
|
|
|
|
|
w.gates[name] = g
|
|
|
|
|
|
return g
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 17:27:40 -04:00
|
|
|
|
// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4].
|
2026-07-22 10:01:47 -04:00
|
|
|
|
func backoffFor(attempts int, jitter float64) time.Duration {
|
2026-07-24 17:27:40 -04:00
|
|
|
|
d := math.Min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter))
|
2026-07-22 10:01:47 -04:00
|
|
|
|
return time.Duration(d * (1 + jitter))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func backoff(attempts int) time.Duration {
|
2026-07-24 17:27:40 -04:00
|
|
|
|
return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive
|
2026-07-22 10:01:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// breaker opens after breakerThreshold consecutive external errors and admits a
|
|
|
|
|
|
// single probe once breakerProbeAfter has elapsed; a success re-closes it.
|
|
|
|
|
|
type breaker struct {
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
failures int
|
|
|
|
|
|
openedAt time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newBreaker() *breaker { return &breaker{} }
|
|
|
|
|
|
|
|
|
|
|
|
func (b *breaker) allow() bool {
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
|
if b.failures < breakerThreshold {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if time.Since(b.openedAt) >= breakerProbeAfter {
|
|
|
|
|
|
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (b *breaker) record(err error) {
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
|
defer b.mu.Unlock()
|
2026-07-22 19:58:57 -04:00
|
|
|
|
// A not-found (from either package) is a definitive answer, not a fault; only real
|
|
|
|
|
|
// errors trip the breaker. Must stay consistent with isTransientExternal.
|
|
|
|
|
|
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
|
2026-07-22 10:01:47 -04:00
|
|
|
|
b.failures = 0
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
b.failures++
|
|
|
|
|
|
if b.failures == breakerThreshold {
|
|
|
|
|
|
b.openedAt = time.Now()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|