mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Comments only; no executable code changed. Verified by comparing the Go
token stream of every touched file before and after: identical.
Removes 375 of the 1104 comment lines this branch added, targeting content
that belongs in a commit message or PR body rather than in the code:
rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats
routing by kind"), refactor history ("as the legacy reader did"), issue
references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs),
and four persistence doc comments that duplicated the interface godoc in
model/artwork.go verbatim.
Comments predating this branch are left untouched.
The ASCII fixture trees in the e2e suites are deliberately kept above the
line budget: they diagram the fixture layout with its expected outcomes,
and every pre-existing block in those files carries one.
404 lines
14 KiB
Go
404 lines
14 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 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
|
|
// hammer external services; below staleAbsentAge to catch younger absences.
|
|
const requestRecheckAge = time.Hour
|
|
|
|
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, and inserts an
|
|
// immediately-eligible recheck for a settled absent row.
|
|
if time.Since(ia.AttemptedAt) > requestRecheckAge {
|
|
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
|
}
|
|
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
|
|
}
|
|
stream, err := s.cache.Get(ctx, &resizedItem{
|
|
hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
|
|
}
|
|
|
|
// 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, 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, 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,
|
|
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
|
|
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
|
|
}
|
|
|
|
// enqueue uses EnqueueBump so an incidental read-through never resets a failed resolution's backoff.
|
|
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 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 not consult 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 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
|
|
}
|
|
|
|
func unixMtime(mtime int64) time.Time {
|
|
if mtime <= 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
|
|
}
|