From 313998fd65a975e88c095ef0abe45c949dc3f757 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 21:19:22 -0400 Subject: [PATCH] feat(artwork): state-backed serving path with provisional read-through --- core/artwork/agent_images.go | 6 + core/artwork/reader_resized.go | 124 +---------- core/artwork/resize.go | 147 +++++++++++++ core/artwork/resolve.go | 35 +++- core/artwork/serving.go | 368 +++++++++++++++++++++++++++++++++ core/artwork/serving_test.go | 323 +++++++++++++++++++++++++++++ core/artwork/wire_providers.go | 1 + 7 files changed, 874 insertions(+), 130 deletions(-) create mode 100644 core/artwork/resize.go create mode 100644 core/artwork/serving.go create mode 100644 core/artwork/serving_test.go diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 659b1c8c2..029235830 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -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 { diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index cd16cbada..7d920e790 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -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) } diff --git a/core/artwork/resize.go b/core/artwork/resize.go new file mode 100644 index 000000000..e213cf5d0 --- /dev/null +++ b/core/artwork/resize.go @@ -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) +} diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 22ad3de52..5caab5332 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -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 diff --git a/core/artwork/serving.go b/core/artwork/serving.go new file mode 100644 index 000000000..7bfc46c81 --- /dev/null +++ b/core/artwork/serving.go @@ -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 +} diff --git a/core/artwork/serving_test.go b/core/artwork/serving_test.go new file mode 100644 index 000000000..529cd5e24 --- /dev/null +++ b/core/artwork/serving_test.go @@ -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() +} diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 868ea8f56..2c93c27e9 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -6,6 +6,7 @@ import ( var Set = wire.NewSet( NewArtwork, + NewService, GetImageCache, NewCacheWarmer, NewWorker,