mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 04:47:17 +02:00
serveHash routed every non-cancel cache error into dangling(), which returns ErrUnavailable and is then rendered as a placeholder at 200 OK. A cache-layer fault was therefore indistinguishable from an album genuinely having no artwork, and left no trace: a broken resize cache silently served placeholders for a quarter of the library while the logs stayed clean. Log the error before falling back, so the cause is recoverable from the logs.
452 lines
17 KiB
Go
452 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/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 signals that a backing file's mtime no longer matches the state
|
|
// row's RefMtime: the stored hash may be stale, so the load is aborted (dangling).
|
|
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 hash (immutable URL match); "" for placeholders
|
|
ETag string // served-representation validator; "" falls back to Hash (full-size original)
|
|
LastUpdated time.Time // zero for placeholders
|
|
Placeholder bool
|
|
}
|
|
|
|
// representationTag identifies a served resized representation for HTTP validation: it changes with
|
|
// the dimensions and the encode settings (CoverArtQuality/EnableWebPEncoding), 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 Service interface {
|
|
// Get serves resolved/provisional artwork; ErrUnavailable or model.ErrNotFound when
|
|
// there is nothing to serve (absent, pending, dangling) — caller picks placeholder vs 404.
|
|
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
|
|
// GetOrPlaceholder parses a raw id token (raw entity ids accepted, as today) and falls
|
|
// back to the kind's placeholder image (never resized, Placeholder=true).
|
|
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
|
|
}
|
|
|
|
func NewService(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Service {
|
|
return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
|
|
}
|
|
|
|
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 the placeholder. An id that matches no entity
|
|
// stays ErrNotFound, so getCoverArt keeps answering error 70 and Jellyfin keeps 404ing.
|
|
if errors.Is(err, ErrUnavailable) {
|
|
return s.placeholder(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 is a full-size request, 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)
|
|
}
|
|
}
|
|
|
|
// requestRecheckAge throttles view-triggered rechecks of an absent entity so repeatedly opening a
|
|
// genuinely-absent page can't hammer external services; below staleAbsentAge to catch younger absences.
|
|
const requestRecheckAge = time.Hour
|
|
|
|
// serveEntity serves an entity whose state the worker owns (album/artist/playlist/radio):
|
|
// found row serves its hash, absent row is unavailable (promoting a stale recheck on view),
|
|
// missing row reads through provisionally.
|
|
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 == "":
|
|
// EnqueueBump preserves an existing backoff row's retry_at; for a settled absent row
|
|
// (no queue row) it inserts a fresh, immediately-eligible recheck.
|
|
if time.Since(ia.AttemptedAt) > requestRecheckAge {
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
|
}
|
|
return nil, ErrUnavailable
|
|
default:
|
|
return s.serveHash(ctx, artID, ia, size, square)
|
|
}
|
|
}
|
|
|
|
// serveHash serves the bytes of a found state row: full-size streams the original, sized
|
|
// goes through the resize cache. A mismatch/open error is dangling (a warm cache still serves).
|
|
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
|
|
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
|
|
}
|
|
|
|
if size == 0 && !square {
|
|
rc, err := openOriginal(ia, art.Mime, s.store)
|
|
if err != nil {
|
|
return s.dangling(ctx, artID)
|
|
}
|
|
return &Image{ReadCloser: rc, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil
|
|
}
|
|
|
|
item := newResizedItem(ia, art.Mime, size, square, s.store, s.ffmpeg)
|
|
stream, err := s.cache.Get(ctx, item)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
return nil, err
|
|
}
|
|
log.Warn(ctx, "artwork: could not serve resized image", "artID", artID, "size", size, err)
|
|
return s.dangling(ctx, artID)
|
|
}
|
|
return &Image{ReadCloser: stream, Hash: ia.Hash, ETag: representationTag(ia.Hash, size, square), LastUpdated: ia.UpdatedAt}, nil
|
|
}
|
|
|
|
// openOriginal opens the full-resolution bytes for a found state row, enforcing 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()
|
|
return nil, errStaleSource
|
|
}
|
|
return f, nil
|
|
}
|
|
// Store-backed (embedded/external/generated): the bytes live in the content-addressed
|
|
// store, but an embedded source still carries the audio file's mtime to detect edits.
|
|
if ia.SourcePath != "" && ia.RefMtime != 0 {
|
|
info, err := os.Stat(ia.SourcePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.ModTime().UnixNano() != ia.RefMtime {
|
|
return nil, errStaleSource
|
|
}
|
|
}
|
|
return store.Open(ia.Hash, mime)
|
|
}
|
|
|
|
// newResizedItem builds the resize-cache reader for a found state row's bytes; shared by
|
|
// the serving path and the worker's precache so both key the cache identically.
|
|
func newResizedItem(ia *model.ItemArtwork, mime string, size int, square bool, store *ImageStore, ffm ffmpeg.FFmpeg) *resizedItem {
|
|
return &resizedItem{
|
|
hash: ia.Hash,
|
|
size: size,
|
|
square: square,
|
|
lastUpdate: ia.UpdatedAt,
|
|
ffmpeg: ffm,
|
|
open: func() (io.ReadCloser, error) { return openOriginal(ia, mime, store) },
|
|
}
|
|
}
|
|
|
|
// provisional does a local-only read-through for an entity with no state row: it enqueues
|
|
// the worker (Bump) and serves any local bytes immediately, never writing a state row.
|
|
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 := resolveItemLocal(ctx, s.ds, s.ffmpeg, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
|
return s.serveResolution(ctx, res, size, square)
|
|
}
|
|
|
|
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash
|
|
// only, no decode). A resolution with no reader is unavailable.
|
|
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
|
|
}
|
|
return s.serveBytes(ctx, hash, data, unixMtime(res.refMtime), size, square)
|
|
}
|
|
|
|
// serveBytes serves in-memory bytes: full-size directly, sized through the resize
|
|
// cache keyed by the byte-hash (so it lines up with the worker's eventual store entry).
|
|
func (s *service) serveBytes(ctx context.Context, hash string, data []byte, lastUpdate time.Time, size int, square bool) (*Image, error) {
|
|
if size == 0 && !square {
|
|
return &Image{ReadCloser: io.NopCloser(bytes.NewReader(data)), Hash: hash, LastUpdated: lastUpdate}, nil
|
|
}
|
|
item := &resizedItem{
|
|
hash: hash,
|
|
size: size,
|
|
square: square,
|
|
lastUpdate: lastUpdate,
|
|
ffmpeg: s.ffmpeg,
|
|
open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil },
|
|
}
|
|
stream, err := s.cache.Get(ctx, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(hash, size, square), LastUpdated: lastUpdate}, nil
|
|
}
|
|
|
|
// serveMediaFile serves a track: own found art wins; an absent row delegates to the album;
|
|
// a missing row extracts embedded art (if eligible, enqueuing) else delegates without enqueue.
|
|
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
|
// Per-track art can be disabled after mf rows were resolved (the setting is not in the
|
|
// config fingerprint). Honor it at serve time so a direct mf- URL falls 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 to album delegation
|
|
case errors.Is(err, model.ErrNotFound):
|
|
// no row → fall through to embedded eligibility / album delegation
|
|
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's fallback: a multi-disc track defers to its disc art
|
|
// (which itself falls back to the album), not straight to the album.
|
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
|
}
|
|
|
|
// provisionalEmbedded extracts a track's embedded art for an immediate serve and always
|
|
// enqueues the track (Bump) so the worker persists state; it never writes a state row.
|
|
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 (truncated frame, no ffmpeg): fall back the way
|
|
// CoverArtID does rather than answer with a placeholder.
|
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
|
}
|
|
return s.serveResolution(ctx, res, size, square)
|
|
}
|
|
|
|
// serveDisc serves disc-level artwork as a pure provisional read-through: no state rows,
|
|
// no enqueue. It tries the disc-folder selection chain and falls 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 its own art, distinct from the
|
|
// album cover, and DiscArtPriority is what expresses that preference.
|
|
selectImage := func() (io.ReadCloser, string, error) {
|
|
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
|
|
return selectImageReader(ctx, artID, funcs...)
|
|
}
|
|
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
|
// Disc art has no state row, so there is no stored content hash — to key the resize cache
|
|
// on, or to fall back to as a validator. Keying on the id and the album's mtime, as the
|
|
// legacy reader did, lets a warm cache answer without touching the filesystem; the chain
|
|
// runs only on a miss. The key carries DiscArtPriority so changing it invalidates.
|
|
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
|
if size == 0 && !square {
|
|
r, _, err := selectImage()
|
|
if err != nil || r == nil {
|
|
return s.Get(ctx, albumArtID, size, square)
|
|
}
|
|
return &Image{ReadCloser: r, ETag: representationTag(key, size, square), LastUpdated: dr.cacheTime()}, nil
|
|
}
|
|
|
|
item := &resizedItem{
|
|
hash: key,
|
|
size: size,
|
|
square: square,
|
|
lastUpdate: dr.cacheTime(),
|
|
ffmpeg: s.ffmpeg,
|
|
open: func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err },
|
|
}
|
|
stream, err := s.cache.Get(ctx, item)
|
|
if err != nil {
|
|
return s.Get(ctx, albumArtID, size, square)
|
|
}
|
|
return &Image{ReadCloser: stream, ETag: representationTag(key, size, square), LastUpdated: dr.cacheTime()}, nil
|
|
}
|
|
|
|
// dangling enqueues a re-resolution at Scan priority and reports the artwork as
|
|
// unavailable, leaving the state row untouched.
|
|
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
|
|
return nil, ErrUnavailable
|
|
}
|
|
|
|
// enqueue schedules a request-triggered re-resolution. It uses EnqueueBump so an incidental
|
|
// read-through never resets a failed resolution's backoff (unlike scan/manual re-resolve).
|
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
|
err := s.ds.ArtworkQueue(ctx).EnqueueBump(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 (s *service) placeholder(kind model.Kind) *Image {
|
|
return placeholderImage(kind)
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
// PlaceholderFor returns the kind-appropriate placeholder for an artwork id, for callers that must
|
|
// serve a placeholder without consulting persisted state (e.g. an access-control denial).
|
|
func PlaceholderFor(id string) *Image {
|
|
artID, _ := model.ParseArtworkID(id)
|
|
return placeholderImage(artID.Kind)
|
|
}
|
|
|
|
type coverArtIDGetter interface {
|
|
CoverArtID() model.ArtworkID
|
|
}
|
|
|
|
// parseArtworkID ports the legacy getArtworkId: parse the token, and if it is a raw
|
|
// entity id, resolve the entity and take 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
|
|
}
|
|
|
|
func unixMtime(mtime int64) time.Time {
|
|
if mtime <= 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
|
|
}
|
|
|
|
// resizedItem is an artworkReader that resizes bytes opened by open() and caches the
|
|
// result under a hash-derived key.
|
|
type resizedItem struct {
|
|
hash string
|
|
size int
|
|
square bool
|
|
lastUpdate time.Time
|
|
ffmpeg ffmpeg.FFmpeg
|
|
open func() (io.ReadCloser, error)
|
|
}
|
|
|
|
func (r *resizedItem) Key() string {
|
|
return fmt.Sprintf("h-%s.%d.%v.%s", r.hash, r.size, r.square, formatQualityTag())
|
|
}
|
|
|
|
func (r *resizedItem) LastUpdated() time.Time { return r.lastUpdate }
|
|
|
|
func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
|
orig, err := r.open()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer orig.Close()
|
|
data, err := readCapped(orig)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square)
|
|
if err != nil || resized == nil {
|
|
// Resize failed or image already within bounds: serve the original bytes.
|
|
return io.NopCloser(bytes.NewReader(data)), r.Key(), nil
|
|
}
|
|
if rc, ok := resized.(io.ReadCloser); ok {
|
|
return rc, r.Key(), nil
|
|
}
|
|
return io.NopCloser(resized), r.Key(), nil
|
|
}
|