navidrome/core/artwork/sources.go
Deluan af984bc817 feat(artwork): advertise album art for tracks whose embedded cover is the album's
Most albums embed the same picture in every track, so clients requesting
per-track cover art (mf- ids) download and cache the same image once per
song. This change hashes the embedded picture at scan time (XXH3 of the
bytes, the same digest the artwork pipeline uses) and stores it on
media_file.embed_art_hash. ToAlbum stores the hash of the track it already
picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash.

A new MediaFile.HasOwnCoverArt replaces the scattered
"HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals
its album's now returns the disc/album artwork id from CoverArtID, so the
Subsonic, Jellyfin and native responses point every matching song at one
cached image. Serving a stale mf- URL follows the same rule, so what is
advertised and what is served agree. Tracks with a different embedded
picture (compilations, per-track art) keep their own ids.

Rows scanned before this change have an empty hash and keep the previous
behavior until a full scan; no config option is added. The album hash is
hydrated onto tracks with one batched PK lookup per page, only when the
page has hashed tracks. BestImageIndex is exported from core/artwork so the
scanner hashes the same picture the resolver serves.
2026-09-07 00:35:21 -04:00

172 lines
5.4 KiB
Go

package artwork
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
"go.senan.xyz/taglib"
)
// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing
// to open it is not evidence the entity has no artwork, so callers must not settle on absent.
var errSourceUnreadable = errors.New("artwork source unreadable")
type sourceFunc func() (r io.ReadCloser, path string, err error)
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
var openErr error
for _, file := range files {
_, name := filepath.Split(file)
match, err := filepath.Match(pattern, strings.ToLower(name))
if err != nil {
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
continue
}
f, err := libFS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err)
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err)
continue
}
return f, file, nil
}
if openErr != nil {
return nil, "", openErr
}
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
}
}
func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if relPath == "" {
return nil, "", nil
}
f, err := libFS.Open(relPath)
if err != nil {
return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
}
rs, ok := f.(io.ReadSeeker)
if !ok {
f.Close()
return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
}
tf, err := taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(relPath),
)
if err != nil {
f.Close()
return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
}
// Close in LIFO order: tf first (it holds rs internally), then f.
defer f.Close()
defer tf.Close()
images := tf.Properties().Images
if len(images) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
}
imageIndex := BestImageIndex(images)
log.Trace(ctx, "Artwork: Using embedded image", "type", images[imageIndex].Type, "path", relPath)
data, err := tf.Image(imageIndex)
if err != nil || len(data) == 0 {
return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
}
return io.NopCloser(bytes.NewReader(data)), relPath, nil
}
}
// Ranks embedded picture types, front covers first.
var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
regexp.MustCompile(`(?i).*front.*`),
regexp.MustCompile(`(?i).*cover.*`),
}
// BestImageIndex returns the index of the embedded picture to serve as cover art, defaulting to the
// first. The scanner hashes the same pick, so a track's embed_art_hash matches what is served.
func BestImageIndex(images []taglib.ImageDesc) int {
for _, regex := range picTypeRegexes {
for i, img := range images {
if regex.MatchString(img.Type) {
return i
}
}
}
return 0
}
// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
// non-trivial refactor with stream/seek implications.
//
// TODO(artwork-musicfs): when the storage backing the library is not local
// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
// func to return (nil, "", nil) so callers fall through cleanly.
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
}
r, err := ffmpeg.ExtractImage(ctx, path)
if err != nil {
return nil, "", err
}
// Validate that the stream actually contains image data by reading the first byte.
// ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
// file has no video/image stream (e.g., an MP3 without embedded art).
buf := make([]byte, 1)
n, err := r.Read(buf)
if n == 0 || err != nil {
r.Close()
return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
}
return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
}
}
// readCloser combines a Reader and a Closer into an io.ReadCloser.
type readCloser struct {
io.Reader
io.Closer
}
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := httpclient.New(5 * time.Second)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}
// An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent
// instead of retrying forever and tripping the artwork breaker.
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
}