package artwork
import (
"bytes"
"cmp"
"context"
"fmt"
"io"
"math"
"math/rand/v2"
"runtime/debug"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
)
const (
workerPollInterval = 5 * time.Second
backoffBase = 5 * time.Second
giveUpAfter = 12 * time.Hour
)
type drainPool struct {
name string
kinds []string
concurrency int
}
type Worker struct {
proc *processor
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
broker events.Broker
pruneMu sync.RWMutex
pools []*drainPool
runCtx context.Context
paused func() bool
gatesMu sync.Mutex
gates map[string]*extGate
}
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
w := &Worker{
proc: &processor{ds: ds, store: store},
cache: imgCache,
ffmpeg: ffmpeg,
broker: broker,
pools: newDrainPools(),
runCtx: context.Background(),
paused: func() bool { return false },
gates: map[string]*extGate{},
}
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
w.proc.pruneLock = w.pruneMu.RLocker()
return w
}
func newDrainPools() []*drainPool {
budget := conf.MaxOpenConns()
local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1)
external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local)
return []*drainPool{
{name: "local", kinds: localDrainKinds, concurrency: local},
{name: "external", kinds: externalDrainKinds, concurrency: external},
}
}
var (
externalDrainKinds = []string{model.KindArtistArtwork.Prefix()}
localDrainKinds = []string{
model.KindAlbumArtwork.Prefix(),
model.KindPlaylistArtwork.Prefix(),
model.KindRadioArtwork.Prefix(),
model.KindMediaFileArtwork.Prefix(),
}
)
func (w *Worker) PauseWhile(paused func() bool) {
w.paused = paused
}
func (w *Worker) Run(ctx context.Context) error {
w.runCtx = ctx
var wg sync.WaitGroup
for _, p := range w.pools {
wg.Go(func() { w.runPool(ctx, p) })
}
wg.Wait()
return nil
}
func (w *Worker) runPool(ctx context.Context, p *drainPool) {
ticker := time.NewTicker(workerPollInterval)
defer ticker.Stop()
for {
n, err := w.drain(ctx, p.concurrency, p.kinds...)
if err != nil && ctx.Err() == nil {
log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err)
}
if ctx.Err() != nil {
return
}
if n > 0 {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *Worker) RunPrune(ctx context.Context) error {
w.pruneMu.Lock()
defer w.pruneMu.Unlock()
return prune(ctx, w.proc.ds, w.proc.store)
}
func (w *Worker) ReconcileConfig(ctx context.Context) error {
return ReconcileConfigFingerprint(ctx, w.proc.ds)
}
func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
return enqueueMissingAll(ctx, w.proc.ds)
}
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
if w.paused() {
return 0, nil
}
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
if err != nil {
return 0, err
}
if len(items) == 0 {
return 0, nil
}
drainStart := time.Now()
ctx = auth.WithAdminUser(ctx, w.proc.ds)
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
var refreshMu sync.Mutex
var refresh []model.ArtworkQueueItem
for _, item := range items {
select {
case sem <- struct{}{}:
case <-ctx.Done():
}
if ctx.Err() != nil {
wg.Wait()
return len(items), nil
}
if w.paused() {
break
}
wg.Go(func() {
defer func() { <-sem }()
out, got := w.process(ctx, item)
if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent {
refreshMu.Lock()
refresh = append(refresh, item)
refreshMu.Unlock()
}
if got != nil {
w.precache(ctx, got)
}
})
}
wg.Wait()
w.broadcastRefresh(ctx, refresh)
log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items),
"refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart))
return len(items), nil
}
var artworkKindToResource = map[model.Kind]string{
model.KindAlbumArtwork: "album",
model.KindArtistArtwork: "artist",
model.KindPlaylistArtwork: "playlist",
model.KindRadioArtwork: "radio",
model.KindMediaFileArtwork: "song",
}
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 {
kind, _ := model.ParseKind(it.ItemKind)
if res, ok := artworkKindToResource[kind]; ok {
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)
}
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
out, got, retryIn := w.safeAcquire(ctx, item)
queue := w.proc.ds.ArtworkQueue()
switch out {
case outcomeFound, outcomeAbsent:
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
case outcomeFoundStale, outcomeFailed:
retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn))
encoded := trace.encode("")
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt),
"budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter)))
break
}
settled := "kept previous state"
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
writeAbsent(ctx, w.proc.ds.Artwork(), item)
settled = "recorded absent"
}
w.recordGiveUp(ctx, item, encoded)
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
if err := queue.DeleteIfUnchanged(ctx, 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)
}
}
return out, got
}
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
kind, ok := model.ParseKind(item.ItemKind)
if !ok {
return
}
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
}
}
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
kind, ok := model.ParseKind(item.ItemKind)
if !ok {
return false
}
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
return err == nil && ia.Hash != ""
}
func (w *Worker) precache(ctx context.Context, got *acquired) {
if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) {
return
}
precacheStart := time.Now()
item := &resizedItem{
hash: got.ia.Hash,
size: conf.Server.UICoverArtSize,
square: true,
ffmpeg: w.ffmpeg,
open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil },
}
stream, err := w.cache.Get(ctx, item)
if err != nil {
log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err)
return
}
_, _ = io.Copy(io.Discard, stream)
_ = stream.Close()
log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID,
"size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart))
}
func backoffFor(attempts int, jitter float64) time.Duration {
d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter))
return time.Duration(d * (1 + jitter))
}
func backoff(attempts int) time.Duration {
return backoffFor(attempts, rand.Float64()*0.8-0.4)
}
func retryDelay(attempts int, hint time.Duration) time.Duration {
return max(backoff(attempts), hint)
}