mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 06:08:46 +01:00
resolveLocalFile swallowed every os.Open error, so uploads, playlist sidecars, a local M3U image and the artist image folder still had the bug that was fixed for folder and embedded sources: a permission or transient I/O error on a file that exists read as "no image here". The worker then settled the item absent and dropped its queue row. Uploads outrank every other source, so an unreadable one now stops the chain rather than letting a lower-priority image be persisted in its place. A genuinely missing file stays a clean miss. Reported by Codex on #5847.
470 lines
16 KiB
Go
470 lines
16 KiB
Go
package artwork
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/draw"
|
|
"image/png"
|
|
"io"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"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
|
|
// a local source that should have been readable wasn't (stale mount, permissions).
|
|
// With no reader: forces failed, so a transient I/O fault never records absent.
|
|
localError 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
|
|
}
|
|
kind, _ := model.ParseKind(item.ItemKind)
|
|
switch kind {
|
|
case model.KindAlbumArtwork:
|
|
return resolveAlbum(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
|
|
case model.KindArtistArtwork:
|
|
return resolveArtist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
|
|
case model.KindPlaylistArtwork:
|
|
return resolvePlaylist(ctx, ds, ag, ffmpeg, item.ItemID, gate, localOnly)
|
|
case model.KindRadioArtwork:
|
|
return resolveRadio(ctx, ds, item.ItemID)
|
|
case model.KindMediaFileArtwork:
|
|
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, localErr bool
|
|
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
|
pattern = strings.TrimSpace(pattern)
|
|
switch {
|
|
case pattern == "embedded":
|
|
res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath)
|
|
if ok {
|
|
res.extError = extErr
|
|
return res, nil
|
|
}
|
|
localErr = localErr || res.localError
|
|
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:
|
|
res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern)
|
|
if ok {
|
|
res.extError = extErr
|
|
return res, nil
|
|
}
|
|
localErr = localErr || res.localError
|
|
}
|
|
}
|
|
return resolution{extError: extErr, localError: localErr}, 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
|
|
}
|
|
upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload")
|
|
if ok {
|
|
return upload, nil
|
|
}
|
|
if upload.localError {
|
|
// The upload outranks every other source; falling through would persist a lower-priority
|
|
// image as if the upload were gone.
|
|
return upload, 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, localErr 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":
|
|
res, ok := resolveArtistImageFolder(ar)
|
|
if ok {
|
|
res.extError = extErr
|
|
return res, nil
|
|
}
|
|
localErr = localErr || res.localError
|
|
case strings.HasPrefix(pattern, "album/"):
|
|
if lib.FS == nil {
|
|
continue
|
|
}
|
|
res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))
|
|
if ok {
|
|
res.extError = extErr
|
|
return res, nil
|
|
}
|
|
localErr = localErr || res.localError
|
|
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, localError: localErr}, 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, localErr bool
|
|
for _, src := range []struct{ path, source string }{
|
|
{pl.UploadedImagePath(), "upload"},
|
|
{findPlaylistSidecarPath(ctx, pl.Path), "folder"},
|
|
} {
|
|
res, ok := resolveLocalFile(src.path, src.source)
|
|
if ok {
|
|
return res, nil
|
|
}
|
|
if res.localError {
|
|
// These outrank the generated grid; falling through would replace them with it.
|
|
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 != "" {
|
|
res, ok := resolveLocalFile(localImg, "folder")
|
|
if ok {
|
|
return res, nil
|
|
}
|
|
if res.localError {
|
|
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 fromURL(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, localError: localErr}, 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
|
|
}
|
|
}
|
|
|
|
func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) {
|
|
if embedRel == "" {
|
|
return resolution{}, false
|
|
}
|
|
abs := lib.Abs(embedRel)
|
|
var unreadable bool
|
|
for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} {
|
|
r, _, err := sf()
|
|
if r != nil {
|
|
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true
|
|
}
|
|
unreadable = unreadable || errors.Is(err, errSourceUnreadable)
|
|
}
|
|
return resolution{localError: unreadable}, false
|
|
}
|
|
|
|
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
|
r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
|
|
if r == nil {
|
|
return resolution{localError: errors.Is(err, errSourceUnreadable)}, 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 path is
|
|
// "no source"; any other open failure says nothing about whether the image exists.
|
|
func resolveLocalFile(path, source string) (resolution, bool) {
|
|
if path == "" {
|
|
return resolution{}, false
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, 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
|
|
}
|