mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
The identity cache key used album.UpdatedAt alone, which a replaced disc image does not necessarily move — the sized response would then serve the old image indefinitely. The legacy reader folded ImportedAt and the folder's ImagesUpdatedAt into its key for exactly this reason, and loadAlbumFoldersPaths already returns that timestamp; the disc reader was discarding it.
251 lines
7.6 KiB
Go
251 lines
7.6 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"
|
|
)
|
|
|
|
// 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
|
|
// imagesUpdatedAt is the 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. Mirrors what the legacy reader folded into its cache key.
|
|
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
|
|
allFolderIDs := make(map[string]bool)
|
|
for _, mf := range mfs {
|
|
allFolderIDs[mf.FolderID] = true
|
|
if firstTrackRel == "" {
|
|
firstTrackRel = filepath.ToSlash(mf.Path)
|
|
}
|
|
}
|
|
|
|
// Resolve folder IDs to library-relative paths
|
|
discFoldersRel := make(map[string]bool)
|
|
if len(allFolderIDs) > 0 {
|
|
folderIDs := make([]string, 0, len(allFolderIDs))
|
|
for id := range allFolderIDs {
|
|
folderIDs = append(folderIDs, id)
|
|
}
|
|
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 == "external":
|
|
// 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 {
|
|
name := path.Base(file)
|
|
stem := strings.TrimSuffix(name, path.Ext(name))
|
|
if !strings.EqualFold(stem, subtitle) {
|
|
continue
|
|
}
|
|
f, err := d.lib.FS.Open(file)
|
|
if err != nil {
|
|
log.Warn(ctx, "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)
|
|
}
|
|
}
|
|
|
|
// globMetaChars holds the substitution metacharacters understood by
|
|
// filepath.Match. The '\' escape character is intentionally excluded:
|
|
// disc art patterns come from user config and never include escaped
|
|
// metachars in practice, and treating '\' 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 pattern.
|
|
//
|
|
// Both pattern and filename must already be lowercased by the caller, which
|
|
// is also expected to have verified that filepath.Match(pattern, filename)
|
|
// is true before calling this function.
|
|
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 returns a sourceFunc that matches image files against a glob
|
|
// pattern. A numbered filename whose number equals the target disc wins over
|
|
// any unnumbered candidate; callers must pass a lowercase pattern.
|
|
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, "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, "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, "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)
|
|
}
|
|
}
|