feat(artwork): state-backed serving path with provisional read-through

This commit is contained in:
Deluan 2026-07-22 21:19:22 -04:00
parent 3a9dadfe34
commit 313998fd65
7 changed files with 874 additions and 130 deletions

View File

@ -18,6 +18,12 @@ func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.Read
return f()
}
// denyGate refuses every external fetch with a definitive not-found, so local-only
// resolution never runs a network step even if an external branch is reached.
func denyGate(_ string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
return nil, "", model.ErrNotFound
}
// bestImageURL returns the largest-Size image URL, skipping empty or unparseable
// URLs; nil when none qualifies.
func bestImageURL(imgs []agents.ExternalImage) *url.URL {

View File

@ -1,46 +1,16 @@
package artwork
import (
"bytes"
"context"
"fmt"
"image"
"image/draw"
"image/jpeg"
"image/png"
"io"
"sync"
"time"
"github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
xdraw "golang.org/x/image/draw"
)
func init() {
conf.AddHook(func() {
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
// package init() and exposes the result only via webp.Dynamic(); there is
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
// crashes (issue #5597), so those builds must be compiled with the
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
// error here and forces the safe WASM path.
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
} else {
log.Debug("Using native libwebp for WebP encoding/decoding")
}
})
}
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
type resizedArtworkReader struct {
artID model.ArtworkID
cacheKey string
@ -113,97 +83,5 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
if err != nil {
return nil, 0, fmt.Errorf("reading image data: %w", err)
}
// Preserve animation for animated images
if isAnimatedGIF(data) {
if a.a.ffmpeg.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
if err == nil {
return r, 0, nil
}
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
}
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
return bytes.NewReader(data), 0, nil
}
return resizeStaticImage(data, a.size, a.square)
}
// toFastScaleType converts images whose concrete type has no optimized scaler
// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
// falls back to a generic per-pixel At()/RGBA() loop that is several times
// slower. Fast-path types are returned unchanged.
func toFastScaleType(img image.Image) image.Image {
switch img.(type) {
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
return img
default:
rgba := image.NewRGBA(img.Bounds())
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
return rgba
}
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, err
}
bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y)
// Clamp size to original dimensions - upscaling wastes resources and adds no information
if size > originalSize {
size = originalSize
}
if originalSize <= size && !square {
return nil, originalSize, nil
}
// Calculate aspect-fit dimensions
srcW, srcH := bounds.Dx(), bounds.Dy()
scale := float64(size) / float64(max(srcW, srcH))
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
var dst *image.NRGBA
var dstRect image.Rectangle
if square {
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
offsetX := (size - dstW) / 2
offsetY := (size - dstH) / 2
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
} else {
// Tight-fit canvas
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
dstRect = dst.Bounds()
}
original = toFastScaleType(original)
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
if conf.Server.EnableWebPEncoding {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
} else if format == "png" || square {
err = png.Encode(buf, dst)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
}
if err != nil {
bufPool.Put(buf)
return nil, originalSize, err
}
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
encoded := make([]byte, buf.Len())
copy(encoded, buf.Bytes())
bufPool.Put(buf)
return bytes.NewReader(encoded), originalSize, nil
return resizeImageData(ctx, a.a.ffmpeg, data, a.size, a.square)
}

147
core/artwork/resize.go Normal file
View File

@ -0,0 +1,147 @@
package artwork
import (
"bytes"
"context"
"fmt"
"image"
"image/draw"
"image/jpeg"
"image/png"
"io"
"sync"
"github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
xdraw "golang.org/x/image/draw"
)
func init() {
conf.AddHook(func() {
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
// package init() and exposes the result only via webp.Dynamic(); there is
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
// crashes (issue #5597), so those builds must be compiled with the
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
// error here and forces the safe WASM path.
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
} else {
log.Debug("Using native libwebp for WebP encoding/decoding")
}
})
}
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// resizeImageData resizes raw image bytes to fit size, preserving animation where
// possible. A nil reader means the image was already within bounds (no resize needed).
func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) {
// Preserve animation for animated images
if isAnimatedGIF(data) {
if ffm.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality)
if err == nil {
return r, 0, nil
}
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
}
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
return bytes.NewReader(data), 0, nil
}
return resizeStaticImage(data, size, square)
}
// toFastScaleType converts images whose concrete type has no optimized scaler
// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
// falls back to a generic per-pixel At()/RGBA() loop that is several times
// slower. Fast-path types are returned unchanged.
func toFastScaleType(img image.Image) image.Image {
switch img.(type) {
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
return img
default:
rgba := image.NewRGBA(img.Bounds())
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
return rgba
}
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, err
}
bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y)
// Clamp size to original dimensions - upscaling wastes resources and adds no information
if size > originalSize {
size = originalSize
}
if originalSize <= size && !square {
return nil, originalSize, nil
}
// Calculate aspect-fit dimensions
srcW, srcH := bounds.Dx(), bounds.Dy()
scale := float64(size) / float64(max(srcW, srcH))
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
var dst *image.NRGBA
var dstRect image.Rectangle
if square {
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
offsetX := (size - dstW) / 2
offsetY := (size - dstH) / 2
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
} else {
// Tight-fit canvas
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
dstRect = dst.Bounds()
}
original = toFastScaleType(original)
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
if conf.Server.EnableWebPEncoding {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
} else if format == "png" || square {
err = png.Encode(buf, dst)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
}
if err != nil {
bufPool.Put(buf)
return nil, originalSize, err
}
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
encoded := make([]byte, buf.Len())
copy(encoded, buf.Bytes())
bufPool.Put(buf)
return bytes.NewReader(encoded), originalSize, nil
}
// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key
// fragment, so flipping either setting invalidates previously-encoded sized artwork.
func formatQualityTag() string {
if conf.Server.EnableWebPEncoding {
return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality)
}
return fmt.Sprintf("q%d", conf.Server.CoverArtQuality)
}

View File

@ -37,16 +37,26 @@ type resolution struct {
// resolveItem walks the kind's priority chain and returns the first hit.
func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc) (resolution, error) {
return resolveItemMode(ctx, ds, ag, ffmpeg, item, gate, false)
}
// resolveItemLocal resolves using only local sources for the serving path's provisional
// read-through: external steps are skipped and the worker-built playlist grid is not assembled.
func resolveItemLocal(ctx context.Context, ds model.DataStore, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem) (resolution, error) {
return resolveItemMode(ctx, ds, nil, ffmpeg, item, denyGate, true)
}
func resolveItemMode(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, gate gateFunc, localOnly bool) (resolution, error) {
if gate == nil {
gate = passthroughGate
}
switch item.ItemKind {
case "al":
return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate)
return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "ar":
return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate)
return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "pl":
return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate)
return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "ra":
return resolveRadio(ctx, ds, item.ItemID)
case "mf":
@ -58,7 +68,7 @@ func resolveItem(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm
// resolveAlbum ports the folder/embedded/external selection from
// reader_album.go, walking conf.Server.CoverArtPriority.
func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc) (resolution, error) {
func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, albumID string, gate gateFunc, localOnly bool) (resolution, error) {
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return resolution{}, err
@ -82,6 +92,9 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ff
return res, nil
}
case pattern == "external":
if localOnly {
continue
}
if r, name, isErr := fetchAlbumImage(ctx, ag, gate, *al); r != nil {
return resolution{reader: r, source: "external:" + name}, nil
} else if isErr {
@ -99,7 +112,7 @@ func resolveAlbum(ctx context.Context, ds model.DataStore, ag *agents.Agents, ff
// resolveArtist ports the upload/folder/external selection from
// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority.
func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc) (resolution, error) {
func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, artistID string, gate gateFunc, localOnly bool) (resolution, error) {
ar, err := ds.Artist(ctx).Get(artistID)
if err != nil {
return resolution{}, err
@ -139,6 +152,9 @@ func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, f
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "external":
if localOnly {
continue
}
if r, name, isErr := fetchArtistImage(ctx, ag, gate, *ar); r != nil {
return resolution{reader: r, source: "external:" + name}, nil
} else if isErr {
@ -172,7 +188,7 @@ func resolveArtist(ctx context.Context, ds model.DataStore, ag *agents.Agents, f
// resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar,
// ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum.
func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc) (resolution, error) {
func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, playlistID string, gate gateFunc, localOnly bool) (resolution, error) {
pl, err := ds.Playlist(ctx).Get(playlistID)
if err != nil {
return resolution{}, err
@ -185,6 +201,11 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents,
if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok {
return res, nil
}
if localOnly {
// The ExternalImageURL step and the 2x2 grid are worker-only; a request must
// not fetch remotely nor sample album art synchronously.
return resolution{}, nil
}
if res, ok, isErr := resolveExternalStep(gate, "m3u", fromPlaylistExternalSource(ctx, *pl)); ok {
return res, nil
} else if isErr {
@ -199,7 +220,7 @@ func resolvePlaylist(ctx context.Context, ds model.DataStore, ag *agents.Agents,
var tiles []image.Image
var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight
for _, albumID := range albumIDs {
res, err := resolveAlbum(ctx, ds, ag, ffm, albumID, gate)
res, err := resolveAlbum(ctx, ds, ag, ffm, albumID, gate, false)
if err != nil {
if tileErr == nil {
tileErr = err

368
core/artwork/serving.go Normal file
View File

@ -0,0 +1,368 @@
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"
)
// 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 // "" for placeholders
LastUpdated time.Time // zero for placeholders
Placeholder bool
}
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)
}
if errors.Is(err, ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
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
}
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)
}
}
// serveEntity serves an entity whose state the worker owns (album/artist/playlist/radio):
// found row serves its hash, absent row is unavailable, 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.Prefix(), 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 == "":
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 := s.openOriginal(ia, art.Mime)
if err != nil {
return s.dangling(ctx, artID)
}
return &Image{ReadCloser: rc, Hash: ia.Hash, LastUpdated: ia.UpdatedAt}, nil
}
item := &resizedItem{
hash: ia.Hash,
size: size,
square: square,
lastUpdate: ia.UpdatedAt,
ffmpeg: s.ffmpeg,
open: func() (io.ReadCloser, error) { return s.openOriginal(ia, art.Mime) },
}
stream, err := s.cache.Get(ctx, item)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
}
return s.dangling(ctx, artID)
}
return &Image{ReadCloser: stream, Hash: ia.Hash, 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 (s *service) openOriginal(ia *model.ItemArtwork, mime string) (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().Unix() != 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().Unix() != ia.RefMtime {
return nil, errStaleSource
}
}
return s.store.Open(ia.Hash, mime)
}
// 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, 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) {
ia, err := s.ds.Artwork(ctx).GetItemArtwork("mf", 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)
}
return s.Get(ctx, mf.AlbumCoverArtID(), 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, _ := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
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, &artwork{ds: s.ds, ffmpeg: s.ffmpeg}, artID)
if err != nil {
return nil, err
}
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
if r, path, err := selectImageReader(ctx, artID, funcs...); err == nil && r != nil {
defer r.Close()
if data, rerr := readCapped(r); rerr == nil {
if hash, herr := HashImage(bytes.NewReader(data)); herr == nil {
return s.serveBytes(ctx, hash, data, unixMtime(mtimeViaFS(dr.lib.FS, path)), size, square)
}
}
}
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
return s.Get(ctx, albumArtID, size, square)
}
// 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
}
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
err := s.ds.ArtworkQueue(ctx).Enqueue(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 {
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 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(mtime, 0)
}
// resizedItem is an artworkReader that resizes bytes opened by open() and caches the
// result under a hash-derived key, sharing the image cache with the legacy readers.
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
}

View File

@ -0,0 +1,323 @@
package artwork
import (
"bytes"
"context"
"image"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Service", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
albumRepo *tests.MockAlbumRepo
mfRepo *tests.MockMediaFileRepo
folderRepo *fakeFolderRepo
libRepo *tests.MockLibraryRepo
ffm *tests.MockFFmpeg
store *ImageStore
imgCache cache.FileCache
svc Service
repoRoot string
coverBytes []byte
)
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
// seedFoundStore installs a store-backed found state (bytes in the content-addressed
// store, no backing file) and returns the hash.
seedFoundStore := func(kind, id string, imgBytes []byte) string {
hash, err := HashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
return hash
}
readAll := func(img *Image) []byte {
GinkgoHelper()
defer img.Close()
data, err := io.ReadAll(img)
Expect(err).ToNot(HaveOccurred())
return data
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
conf.Server.EnableWebPEncoding = false
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
conf.Server.DiscArtPriority = "cover.*"
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
albumRepo = tests.CreateMockAlbumRepo()
mfRepo = tests.CreateMockMediaFileRepo()
folderRepo = &fakeFolderRepo{}
libRepo = &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedArtwork: artRepo,
MockedArtworkQueue: queueRepo,
MockedAlbum: albumRepo,
MockedMediaFile: mfRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
store = NewImageStore(GinkgoT().TempDir())
imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
r, _, err := arg.(artworkReader).Reader(ctx)
return r, err
})
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
svc = NewService(ds, imgCache, store, ffm)
})
Describe("found state", func() {
It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() {
seedFoundStore("al", "al1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
Expect(err).ToNot(HaveOccurred())
resized := readAll(img)
cfg, _, err := image.DecodeConfig(bytes.NewReader(resized))
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(100))
// Delete the store file: a warm resize-cache entry must keep serving without
// ever touching the original (the stale-serve self-heal).
hash, _ := HashImage(bytes.NewReader(coverBytes))
Expect(store.Remove(hash, "image/jpeg", time.Now().Add(time.Hour))).To(Succeed())
Eventually(func(g Gomega) {
img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(readAll(img2)).To(Equal(resized))
}).Should(Succeed())
})
It("streams a file-backed found image at full size", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
mtime := fileMtime(imgPath)
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
})).To(Succeed())
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
It("enforces the mtime rule on the sized (loader) path too", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("returns ErrUnavailable for an absent state without enqueuing", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "al4"})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data).To(BeEmpty())
})
})
Describe("provisional read-through", func() {
It("serves local folder art, enqueues a Bump, and writes no state row", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}})
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("media file", func() {
It("serves a track's own found art", func() {
seedFoundStore("mf", "mf1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("delegates to the album when the track's state is absent", func() {
seedFoundStore("al", "albm", coverBytes)
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")]
Expect(mfEnq).To(BeFalse())
})
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
seedFoundStore("al", "albn", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")]
Expect(mfEnq).To(BeFalse())
})
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
mfRepo.SetData(model.MediaFiles{{
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(len(readAll(img))).To(BeNumerically(">", 0))
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork("mf", "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("disc", func() {
It("serves a local disc-folder image", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to album art when no disc image matches", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}})
seedFoundStore("al", "aldc2", coverBytes)
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
})
Describe("GetOrPlaceholder", func() {
It("accepts a raw entity id and serves its cover art", func() {
albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}})
seedFoundStore("al", "rawal", coverBytes)
img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to the album placeholder ignoring size and square", func() {
img, err := svc.GetOrPlaceholder(ctx, "", 300, true)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
Expect(img.Hash).To(BeEmpty())
Expect(img.LastUpdated).To(BeZero())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
It("falls back to the artist placeholder for an absent artist", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
ph, err := resources.FS().Open(consts.PlaceholderArtistArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
})
})
func fileMtime(path string) int64 {
GinkgoHelper()
info, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
return info.ModTime().Unix()
}

View File

@ -6,6 +6,7 @@ import (
var Set = wire.NewSet(
NewArtwork,
NewService,
GetImageCache,
NewCacheWarmer,
NewWorker,