navidrome/core/artwork/resolve.go
Deluan aba7ed925c fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance
Two serving-correctness fixes from review:
- serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt
  was turned off (the setting isn't in the config fingerprint, so found rows aren't
  reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to
  disc/album art.
- The file-backed staleness check compared whole-second mtimes, so a same-second content
  replacement (two writes in one second, or timestamp-preserving tools) could serve
  different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds
  (no schema change; int64 column), detecting sub-second changes where the filesystem
  records them.
2026-07-23 14:08:08 -04:00

463 lines
15 KiB
Go

package artwork
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/draw"
"image/png"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model"
)
// resolution is one attempted acquisition outcome for an entity.
type resolution struct {
reader io.ReadCloser // nil when no source yielded an image
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath
// external source errored/timed out. With no reader: forces failed (never absent).
// On a hit: a higher-priority external step failed—serve this, but retry later.
extError bool
}
// 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, localOnly)
case "ar":
return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "pl":
return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
case "ra":
return resolveRadio(ctx, ds, item.ItemID)
case "mf":
return resolveMediaFile(ctx, ds, ffmpeg, item.ItemID)
default:
return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind)
}
}
// 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, localOnly bool) (resolution, error) {
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return resolution{}, err
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al)
if err != nil {
return resolution{}, err
}
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
if err != nil {
return resolution{}, err
}
var extErr bool
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok {
res.extError = extErr
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 {
extErr = true
}
case len(imgFiles) > 0:
if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok {
res.extError = extErr
return res, nil
}
}
}
return resolution{extError: extErr}, nil
}
// 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, localOnly bool) (resolution, error) {
ar, err := ds.Artist(ctx).Get(artistID)
if err != nil {
return resolution{}, err
}
if res, ok := resolveLocalFile(ar.UploadedImagePath(), "upload"); ok {
return res, nil
}
// Only consider albums where the artist is the sole album artist, same as reader_artist.go.
als, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"album_artist_id": artistID},
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
},
})
if err != nil {
return resolution{}, err
}
albumPaths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, als...)
if err != nil {
return resolution{}, err
}
artistFolder, _, err := loadArtistFolder(ctx, ds, als, albumPaths)
if err != nil {
return resolution{}, err
}
var lib libraryView
if len(als) > 0 {
lib, err = loadLibraryView(ctx, ds, als[0].LibraryID)
if err != nil {
return resolution{}, err
}
}
var extErr bool
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
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 {
extErr = true
}
case pattern == "image-folder":
if res, ok := resolveArtistImageFolder(ar); ok {
res.extError = extErr
return res, nil
}
case strings.HasPrefix(pattern, "album/"):
if lib.FS == nil {
continue
}
if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok {
res.extError = extErr
return res, nil
}
default:
if lib.FS == nil || artistFolder == "" {
continue
}
if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok {
res.extError = extErr
return res, nil
}
}
}
return resolution{extError: extErr}, nil
}
// 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, localOnly bool) (resolution, error) {
pl, err := ds.Playlist(ctx).Get(playlistID)
if err != nil {
return resolution{}, err
}
var extErr bool
if res, ok := resolveLocalFile(pl.UploadedImagePath(), "upload"); ok {
return res, nil
}
if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok {
return res, nil
}
// A local ExternalImageURL is a file-backed reference: serve it in place (staleness-checked,
// and available even on the request path). Only http(s) URLs need the gated remote fetch.
localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL)
if localImg != "" {
if res, ok := resolveLocalFile(localImg, "folder"); ok {
return res, nil
}
}
if localOnly {
// The remote ExternalImageURL fetch and the 2x2 grid are worker-only; a request must
// not fetch remotely nor sample album art synchronously.
return resolution{}, nil
}
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
sf := func() (io.ReadCloser, string, error) { return fetchPlaylistImageURL(ctx, remoteImg) }
if res, ok, isErr := resolveExternalStep(gate, "m3u", sf); ok {
return res, nil
} else if isErr {
extErr = true
}
}
albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
if err != nil {
return resolution{}, err
}
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, false)
if err != nil {
if tileErr == nil {
tileErr = err
}
continue
}
if res.extError {
extErr = true
}
if res.reader == nil {
continue
}
tile, decErr := decodeTile(res.reader)
res.reader.Close()
if decErr == nil {
tiles = append(tiles, tile)
}
if len(tiles) == 4 {
break
}
}
if len(tiles) == 0 {
// A tile-level failure must never resolve as a clean absent: propagate
// internal errors, and force extError for external ones.
if tileErr != nil {
return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr)
}
return resolution{extError: extErr}, nil
}
// Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles.
switch len(tiles) {
case 2:
tiles = append(tiles, tiles[1], tiles[0])
case 3:
tiles = append(tiles, tiles[0])
}
r, err := assembleTiles(tiles)
if err != nil {
return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error
}
return resolution{reader: r, source: "generated", extError: extErr}, nil
}
// resolveRadio ports reader_radio.go: only an uploaded image, no fallback.
func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (resolution, error) {
r, err := ds.Radio(ctx).Get(radioID)
if err != nil {
return resolution{}, err
}
res, _ := resolveLocalFile(r.UploadedImagePath(), "upload")
return res, nil
}
// resolveMediaFile resolves a track's own embedded art only; there is no folder or
// external fallback, so disabled/missing cover art is a definitive absent.
func resolveMediaFile(ctx context.Context, ds model.DataStore, ffm ffmpeg.FFmpeg, id string) (resolution, error) {
mf, err := ds.MediaFile(ctx).Get(id)
if err != nil {
return resolution{}, err
}
if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt {
return resolution{}, nil
}
lib, err := loadLibraryView(ctx, ds, mf.LibraryID)
if err != nil {
return resolution{}, err
}
res, _ := resolveEmbedded(ctx, lib, ffm, mf.Path)
return res, nil
}
// resolveExternalStep runs a single external sourceFunc through the named gate; used by
// the playlist ExternalImageURL step. ok reports a hit; extErr reports a non-not-found
// error (a not-found is a definitive "no", not a failure).
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
r, path, err := gate(name, sf)
if r != nil {
return resolution{reader: r, source: "external", sourcePath: path}, true, false
}
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
}
// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path
// (served file-backed) or a remote http(s) URL (fetched and stored); at most one is set.
func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) {
if imageURL == "" {
return "", nil
}
u, err := url.Parse(imageURL)
if err != nil {
return imageURL, nil // unparseable → treat as a local path
}
switch u.Scheme {
case "http", "https":
return "", u
case "file":
return u.Path, nil
default:
return imageURL, nil
}
}
// Like sources.go's fromURL but maps 404/410 to ErrNotFound (definitive), so a stale M3U
// cover URL falls through to the grid instead of retrying forever and tripping the breaker.
func fetchPlaylistImageURL(ctx context.Context, imageURL *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
resp.Body.Close()
return nil, "", model.ErrNotFound
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageURL, resp.Status)
}
return resp.Body, imageURL.String(), nil
}
func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) {
if embedRel == "" {
return resolution{}, false
}
abs := lib.Abs(embedRel)
for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} {
if r, _, _ := sf(); r != nil {
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true
}
}
return resolution{}, false
}
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
r, path, _ := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
if r == nil {
return resolution{}, false
}
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
}
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
folder := conf.Server.ArtistImageFolder
if folder == "" {
return resolution{}, false
}
return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder")
}
func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) {
r, path, _ := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)()
if r == nil {
return resolution{}, false
}
return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true
}
// resolveLocalFile opens an absolute path directly (uploads, image-folder). A
// missing or unreadable path is "no source", not an error.
func resolveLocalFile(path, source string) (resolution, bool) {
if path == "" {
return resolution{}, false
}
f, err := os.Open(path)
if err != nil {
return resolution{}, false
}
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
}
func mtimeOf(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.ModTime().UnixNano()
}
// mtimeViaFS stats through the library FS instead of a joined absolute path,
// since library roots in tests may not be real OS paths (e.g. testfile://).
func mtimeViaFS(fsys fs.FS, name string) int64 {
if fsys == nil || name == "" {
return 0
}
info, err := fs.Stat(fsys, name)
if err != nil {
return 0
}
return info.ModTime().UnixNano()
}
// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/
// createTiledImage, reusing the same rect/fillCenter cropping helpers.
// decodeTile runs on every sampled album's resolved bytes before processItem's
// own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too.
func decodeTile(r io.ReadCloser) (image.Image, error) {
data, err := readCapped(r)
if err != nil {
return nil, err
}
img, _, err := decodeCapped(data)
if err != nil {
return nil, err
}
return fillCenter(img, tileSize/2, tileSize/2), nil
}
func assembleTiles(tiles []image.Image) (io.ReadCloser, error) {
buf := new(bytes.Buffer)
var err error
if len(tiles) == 4 {
rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
err = png.Encode(buf, rgba)
} else {
err = png.Encode(buf, tiles[0])
}
if err != nil {
return nil, err
}
return io.NopCloser(buf), nil
}