mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-04 01:08:54 +01:00
Replace cache.Transient with an artwork-local *deferredAnimation error returned by resizedItem.Reader. The file cache already stores nothing when its loader fails, so the generic cache no longer needs a transient mode. The error carries the GIF bytes and the static stand-in, replacing the deferAnimated callback. The cache's loader-error path now cancels the entry writer, so readers that joined the miss fail with the loader's error instead of reading an empty entry, and the removal no longer blocks on them. The background job resizes the bytes it already holds instead of re-reading them, orOriginal centralizes the serve-the-original fallback, and warmCache shares the store-and-drain step with the worker's precache.
471 lines
17 KiB
Go
471 lines
17 KiB
Go
package artwork
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/resources"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
)
|
|
|
|
var ErrUnavailable = errors.New("artwork unavailable")
|
|
|
|
// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
|
|
var errStaleSource = errors.New("artwork: source file changed since resolution")
|
|
|
|
// Image is one servable artwork response.
|
|
type Image struct {
|
|
io.ReadCloser
|
|
Hash string // pixel identity; "" for placeholders
|
|
ETag string // representation validator; "" means Hash applies (full-size original)
|
|
LastUpdated time.Time
|
|
Placeholder bool
|
|
Transient bool // stand-in while the real representation is produced in the background
|
|
}
|
|
|
|
// representationTag varies with dimensions and encode settings, so a config change invalidates
|
|
// a revalidating client's cache even though the pixel hash is unchanged.
|
|
func representationTag(hash string, size int, square bool) string {
|
|
return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
|
|
}
|
|
|
|
type Artwork interface {
|
|
// Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
|
|
// the id resolves to nothing, so the caller can pick placeholder vs 404.
|
|
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
|
|
// GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
|
|
// kind's placeholder image (never resized, Placeholder=true).
|
|
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
|
|
}
|
|
|
|
func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
|
|
return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
|
|
}
|
|
|
|
// entityExists reports whether the entity an artwork id points at is still there: state rows
|
|
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
|
|
func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
|
|
var found bool
|
|
var err error
|
|
switch artID.Kind {
|
|
case model.KindArtistArtwork:
|
|
found, err = ds.Artist(ctx).Exists(artID.ID)
|
|
case model.KindAlbumArtwork:
|
|
found, err = ds.Album(ctx).Exists(artID.ID)
|
|
case model.KindMediaFileArtwork:
|
|
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
|
case model.KindPlaylistArtwork:
|
|
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
|
case model.KindRadioArtwork:
|
|
found, err = ds.Radio(ctx).Exists(artID.ID)
|
|
case model.KindDiscArtwork:
|
|
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
|
if perr != nil {
|
|
return false
|
|
}
|
|
found, err = ds.Album(ctx).Exists(albumID)
|
|
default:
|
|
return false
|
|
}
|
|
return err == nil && found
|
|
}
|
|
|
|
type service struct {
|
|
ds model.DataStore
|
|
cache cache.FileCache
|
|
store *ImageStore
|
|
ffmpeg ffmpeg.FFmpeg
|
|
}
|
|
|
|
func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
|
|
artID, err := s.parseArtworkID(ctx, id)
|
|
var img *Image
|
|
if err == nil {
|
|
img, err = s.Get(ctx, artID, size, square)
|
|
}
|
|
// Only a resolvable entity with no art gets a placeholder; an unknown id must stay
|
|
// ErrNotFound so callers can still answer 404 / Subsonic error 70.
|
|
if errors.Is(err, ErrUnavailable) {
|
|
return placeholderImage(artID.Kind), nil
|
|
}
|
|
return img, err
|
|
}
|
|
|
|
func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
if artID.ID == "" {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if size < 0 {
|
|
size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
|
|
}
|
|
switch artID.Kind {
|
|
case model.KindDiscArtwork:
|
|
return s.serveDisc(ctx, artID, size, square)
|
|
case model.KindMediaFileArtwork:
|
|
return s.serveMediaFile(ctx, artID, size, square)
|
|
default:
|
|
return s.serveEntity(ctx, artID, size, square)
|
|
}
|
|
}
|
|
|
|
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
return s.provisional(ctx, artID, size, square)
|
|
case err != nil:
|
|
return nil, err
|
|
case ia.Hash == "":
|
|
// Settled absent: only an explicit reprocess or refresh retries it.
|
|
return nil, ErrUnavailable
|
|
default:
|
|
return s.serveHash(ctx, artID, ia, size, square)
|
|
}
|
|
}
|
|
|
|
// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
|
|
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
|
|
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
|
|
size int, square bool, open func() (io.ReadCloser, error),
|
|
) (*Image, error) {
|
|
if size == 0 && !square {
|
|
rc, err := open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rc == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate}
|
|
if hash == "" {
|
|
img.ETag = representationTag(key, size, square)
|
|
}
|
|
return img, nil
|
|
}
|
|
// Without a cache a background conversion could never be served, so convert inline instead.
|
|
item := &resizedItem{hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
|
|
deferAnimated: s.cache.Available(ctx)}
|
|
stream, err := s.cache.Get(ctx, item)
|
|
if deferred, ok := errors.AsType[*deferredAnimation](err); ok {
|
|
s.convertInBackground(item, deferred.data)
|
|
return &Image{ReadCloser: io.NopCloser(deferred.standIn), Hash: hash, LastUpdated: lastUpdate, Transient: true}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
|
|
}
|
|
|
|
// animConvertSlot bounds animated conversions process-wide: they outlive their request, so no
|
|
// request throttle limits them.
|
|
var animConvertSlot = make(chan struct{}, 1)
|
|
|
|
const animConvertTimeout = time.Minute
|
|
|
|
// convertInBackground caches item's conversion of data. When the slot is busy it does nothing,
|
|
// and a later request for the key tries again.
|
|
func (s *service) convertInBackground(item *resizedItem, data []byte) {
|
|
select {
|
|
case animConvertSlot <- struct{}{}:
|
|
default:
|
|
return
|
|
}
|
|
key, size, square := item.Key(), item.size, item.square
|
|
go func() {
|
|
defer func() { <-animConvertSlot }()
|
|
ctx, cancel := context.WithTimeout(context.Background(), animConvertTimeout)
|
|
defer cancel()
|
|
start := time.Now()
|
|
resized, _, err := resizeImageData(ctx, s.ffmpeg, data, size, square)
|
|
out, _ := io.ReadAll(orOriginal(data, resized, err))
|
|
// Converting before touching the cache keeps the entry from blocking readers for the whole conversion.
|
|
if err := warmCache(ctx, s.cache, &storedItem{key: key, data: out}); err != nil {
|
|
log.Warn(ctx, "Artwork: Could not cache animated image", "key", key, err)
|
|
return
|
|
}
|
|
log.Debug(ctx, "Artwork: Converted animated image", "key", key, "bytes", len(out), "elapsed", time.Since(start))
|
|
}()
|
|
}
|
|
|
|
// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
|
|
// cancelled request is not: it must not enqueue a re-resolution.
|
|
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
|
|
// Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
|
|
if !entityExists(ctx, s.ds, artID) {
|
|
return nil, ErrUnavailable
|
|
}
|
|
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return s.dangling(ctx, artID)
|
|
}
|
|
return nil, err
|
|
}
|
|
img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
|
|
func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
return nil, err
|
|
}
|
|
log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err)
|
|
return s.dangling(ctx, artID)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
|
|
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
|
|
if isFileBacked(ia.Source) {
|
|
f, err := os.Open(ia.SourcePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
|
|
f.Close()
|
|
log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
|
|
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
|
return nil, errStaleSource
|
|
}
|
|
return f, nil
|
|
}
|
|
// Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
|
|
if ia.SourcePath != "" && ia.RefMtime != 0 {
|
|
info, err := os.Stat(ia.SourcePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.ModTime().UnixNano() != ia.RefMtime {
|
|
log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
|
|
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
|
return nil, errStaleSource
|
|
}
|
|
}
|
|
return store.Open(ia.Hash, mime)
|
|
}
|
|
|
|
// provisional serves local bytes for an entity with no state row, enqueuing the worker but
|
|
// never writing a state row itself.
|
|
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
|
|
res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
|
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
|
|
"source", res.source, "hit", res.reader != nil)
|
|
return s.serveResolution(ctx, res, size, square)
|
|
}
|
|
|
|
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
|
|
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
|
|
if res.reader == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
defer res.reader.Close()
|
|
data, err := readCapped(res.reader)
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
hash, err := hashImage(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
|
|
return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
|
|
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
|
|
}
|
|
|
|
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
|
// must fall back to disc/album instead of serving stale persisted embedded art.
|
|
if !conf.Server.EnableMediaFileCoverArt {
|
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
|
}
|
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
|
switch {
|
|
case err == nil && ia.Hash != "":
|
|
return s.serveHash(ctx, artID, ia, size, square)
|
|
case err == nil:
|
|
// absent row: fall through
|
|
case errors.Is(err, model.ErrNotFound):
|
|
// no row: fall through
|
|
default:
|
|
return nil, err
|
|
}
|
|
noRow := errors.Is(err, model.ErrNotFound)
|
|
|
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
|
|
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
|
|
}
|
|
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
|
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
|
}
|
|
|
|
// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
|
|
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
|
|
lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
|
if !ok {
|
|
// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
|
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
|
}
|
|
return s.serveResolution(ctx, res, size, square)
|
|
}
|
|
|
|
// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
|
|
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
dr, err := newDiscArtworkReader(ctx, s.ds, artID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
|
selectImage := func() (io.ReadCloser, error) {
|
|
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
|
|
return res.reader, err
|
|
}
|
|
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
|
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
|
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
|
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
|
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
return nil, err
|
|
}
|
|
return s.Get(ctx, albumArtID, size, square)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
|
|
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
|
|
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
|
|
return nil, ErrUnavailable
|
|
}
|
|
|
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
|
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
|
ItemKind: artID.Kind.Prefix(),
|
|
ItemID: artID.ID,
|
|
ImageType: model.ImageTypePrimary,
|
|
Priority: priority,
|
|
})
|
|
if err != nil {
|
|
log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err)
|
|
}
|
|
}
|
|
|
|
func placeholderImage(kind model.Kind) *Image {
|
|
path := consts.PlaceholderAlbumArt
|
|
if kind == model.KindArtistArtwork {
|
|
path = consts.PlaceholderArtistArt
|
|
}
|
|
r, _ := resources.FS().Open(path)
|
|
return &Image{ReadCloser: r, Placeholder: true}
|
|
}
|
|
|
|
type coverArtIDGetter interface {
|
|
CoverArtID() model.ArtworkID
|
|
}
|
|
|
|
// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
|
|
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
|
|
if id == "" {
|
|
return model.ArtworkID{}, ErrUnavailable
|
|
}
|
|
if artID, err := model.ParseArtworkID(id); err == nil {
|
|
return artID, nil
|
|
}
|
|
entity, err := model.GetEntityByID(ctx, s.ds, id)
|
|
if err != nil {
|
|
return model.ArtworkID{}, err
|
|
}
|
|
if e, ok := entity.(coverArtIDGetter); ok {
|
|
return e.CoverArtID(), nil
|
|
}
|
|
return model.ArtworkID{}, model.ErrNotFound
|
|
}
|
|
|
|
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
|
|
// the walk and reports the winning source, without ever writing artwork state.
|
|
type TracingResolver struct {
|
|
inner *resolver
|
|
trace *ChainTrace
|
|
}
|
|
|
|
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
|
|
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
|
|
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
|
|
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
|
|
inner := newLocalResolver(ds, ffm)
|
|
if live {
|
|
inner = newResolver(ds, ag, ffm, passthroughGate)
|
|
}
|
|
return &TracingResolver{inner: inner, trace: t}
|
|
}
|
|
|
|
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
|
|
// ("" when none produced an image).
|
|
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
|
|
switch kind {
|
|
case model.KindArtistArtwork:
|
|
return r.explain(ctx, r.inner.resolveArtist, id)
|
|
case model.KindAlbumArtwork:
|
|
return r.explain(ctx, r.inner.resolveAlbum, id)
|
|
case model.KindDiscArtwork:
|
|
return r.explain(ctx, r.inner.resolveDisc, id)
|
|
case model.KindMediaFileArtwork:
|
|
return r.explain(ctx, r.inner.resolveMediaFile, id)
|
|
}
|
|
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
|
|
}
|
|
|
|
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
|
|
// would close the reader either.
|
|
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
|
|
res, err := resolve(withTrace(ctx, r.trace), id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if res.reader != nil {
|
|
_ = res.reader.Close()
|
|
}
|
|
return res.source, nil
|
|
}
|
|
|
|
func unixMtime(mtime int64) time.Time {
|
|
if mtime <= 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
|
|
}
|