navidrome/core/artwork/disc.go
Deluan a1b9976521 refactor(artwork): export the trace outcome vocabulary
The CLI hardcoded the outcome literals and the "external:" prefix, so renaming a
constant's value in core/artwork left cmd compiling and the suite green while
`artwork explain` silently degraded its verdict.

Renaming a value now fails the golden vocabulary test in core/artwork and the
explainResult tests in cmd.
2026-08-14 15:13:55 -04:00

237 lines
7 KiB
Go

package artwork
import (
"context"
"fmt"
"io"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
)
// discArtworkReader resolves disc-level artwork from a library's folder images
// and embedded tags. It is used by the serving path's provisional disc read-through.
type discArtworkReader struct {
album model.Album
discNumber int
imgFiles []string // library-relative, forward-slash, no leading slash
discFoldersRel map[string]bool // library-relative folder paths
isMultiFolder bool
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
lib libraryView
// Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be
// replaced without the album row changing, so this is what makes a cache key notice it.
imagesUpdatedAt time.Time
}
// cacheTime is the disc image's validity stamp: any of these moving means the selection may
// have changed.
func (d *discArtworkReader) cacheTime() time.Time {
return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt)
}
func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
if err != nil {
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
}
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return nil, err
}
_, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al)
if err != nil {
return nil, err
}
var imagesUpdatedAt time.Time
if albumImagesAt != nil {
imagesUpdatedAt = *albumImagesAt
}
// Query mediafiles for this album + disc to find folder associations and first track
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "track_number",
Order: "ASC",
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
})
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
if err != nil {
return nil, err
}
// Build disc folder set and find first track. mf.Path is already library-relative.
var firstTrackRel string
for _, mf := range mfs {
if mf.Path != "" {
firstTrackRel = filepath.ToSlash(mf.Path)
break
}
}
folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID }))
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(folderIDs) > 0 {
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"folder.id": folderIDs},
})
if err != nil {
return nil, err
}
for _, f := range folders {
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
discFoldersRel[rel] = true
imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt)
}
}
return &discArtworkReader{
album: *al,
discNumber: discNumber,
imgFiles: imgFiles,
discFoldersRel: discFoldersRel,
isMultiFolder: len(al.FolderIDs) > 1,
firstTrackRel: firstTrackRel,
lib: lib,
imagesUpdatedAt: imagesUpdatedAt,
}, nil
}
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == ExternalCandidate:
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
}
case len(d.imgFiles) > 0:
ff = append(ff, d.fromExternalFile(ctx, pattern))
}
}
return ff
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
// (filename without extension) equals the disc subtitle (case-insensitive).
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range d.imgFiles {
stem := utils.BaseName(file)
if !strings.EqualFold(stem, subtitle) {
continue
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
}
}
// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar
// would misalign the literal-prefix extraction in extractDiscNumber.
const globMetaChars = "*?["
// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style
// glob. Caller must lowercase both args and have already verified the match.
func extractDiscNumber(pattern, filename string) (int, bool) {
metaIdx := strings.IndexAny(pattern, globMetaChars)
if metaIdx < 0 {
return 0, false
}
prefix := pattern[:metaIdx]
if !strings.HasPrefix(filename, prefix) {
return 0, false
}
start := len(prefix)
end := start
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
end++
}
if end == start {
return 0, false
}
num, err := strconv.Atoi(filename[start:end])
if err != nil {
return 0, false
}
return num, true
}
// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered
// filename whose number equals the target disc wins over any unnumbered candidate.
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
return func() (io.ReadCloser, string, error) {
var fallbacks []string
for _, file := range d.imgFiles {
name := strings.ToLower(path.Base(file))
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
continue
}
if !isLiteral {
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
if num != d.discNumber {
continue
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
}
if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
continue
}
fallbacks = append(fallbacks, file)
}
for _, file := range fallbacks {
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
}
}