mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 22:27:10 +01:00
* fix(artwork): do not promote artist folder to album folder * use filesystem join for path instead * fix(artwork): resolve artist folder for albums with disc subfolders Dropping the promoted album root from the artist reader's paths fixed the flat-album case but broke albums whose tracks live in disc subfolders: the promoted root was what kept the byte-wise longest common prefix on a directory boundary. Without it, CD1/CD2 siblings share the fragment "Album/CD", so the artist folder resolved one level too deep and album/artist.* won over artist.*. loadAlbumFoldersPaths now collapses each album to its own root before the paths are compared across albums, so a disc-split album contributes its album folder rather than each disc folder. Folders no album claims (the promoted parent) are still returned unchanged, keeping the album, disc and mediafile readers unaffected. The prefix math moves into commonDir, which appends a trailing separator so the comparison lands on segment boundaries - this also fixes sibling folders sharing a name prefix (Album/Album2). Adds e2e coverage for the case the branch fixes (artist.* with no album/ fallback, which failed before this branch), the disc-subfolder regression, and a multi-album guard that pins the scope boundary. * test(artwork): cover artist whose albums are all disc-split The existing specs cover a single disc-split album and a disc-split album alongside a flat one, but not an artist where every album is split into disc subfolders. That layout resolves correctly because the albums diverge one level above the disc folders, which re-anchors the common prefix - a property worth pinning so a future change to the path math can't silently break it. * fix(artwork): resolve artist folder when albums share a folder loadArtistFolder decided whether to climb above the common directory by comparing len(paths) against 2. That count stands in for "how many distinct album roots are there", and the two diverge when an artist has two albums in the same folder: the slice holds two identical entries, the climb is skipped, and the artist folder resolves to the album folder. Deduplicate the roots so the branch tests the property it means to test. Replace the includeParent flag with two functions split by audience. The flag was a caller-identity switch - constant true at the album, disc and mediafile readers, all of which discard the returned paths, and constant false at the only caller that reads them. The artist path now has loadArtistAlbumRoots, which collapses each album to its own root and never consults albumRootParent; loadAlbumFoldersPaths goes back to taking a single album and always promoting the parent. Both share the folder load and image aggregation. This also makes albumRootParent's contract structural. Its guard only rejects an artist folder when it can find audio outside the album being resolved, so passing every album of an artist at once made the check meaningless; taking a single album means it can no longer be called that way. Drops the per-album grouping and unclaimed-folder reconciliation from the album path, where the result was discarded, and removes 15 mechanical true arguments from the tests. --------- Co-authored-by: Deluan <deluan@navidrome.org>
359 lines
12 KiB
Go
359 lines
12 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
)
|
|
|
|
const (
|
|
// maxArtistFolderTraversalDepth defines how many directory levels to search
|
|
// when looking for artist images (artist folder + parent directories)
|
|
maxArtistFolderTraversalDepth = 3
|
|
)
|
|
|
|
type artistReader struct {
|
|
cacheKey
|
|
a *artwork
|
|
provider external.Provider
|
|
artist model.Artist
|
|
artistFolder string
|
|
imgFiles []string
|
|
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
|
lib libraryView
|
|
}
|
|
|
|
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
|
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Only consider albums where the artist is the sole album artist.
|
|
als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.And{
|
|
squirrel.Eq{"album_artist_id": artID.ID},
|
|
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var lib libraryView
|
|
if len(als) > 0 {
|
|
lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
a := &artistReader{
|
|
a: artwork,
|
|
provider: provider,
|
|
artist: *ar,
|
|
artistFolder: artistFolder,
|
|
imgFiles: imgFiles,
|
|
lib: lib,
|
|
}
|
|
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
|
// change _after_ retrieving from external sources, making the key invalid
|
|
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
|
|
|
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
|
if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
|
a.cacheKey.lastUpdate = *ar.UpdatedAt
|
|
}
|
|
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
|
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
|
}
|
|
if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
|
|
a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
|
|
if a.imgFolderImgPath != "" {
|
|
if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
|
|
a.cacheKey.lastUpdate = info.ModTime()
|
|
}
|
|
}
|
|
}
|
|
a.cacheKey.artID = artID
|
|
return a, nil
|
|
}
|
|
|
|
func (a *artistReader) Key() string {
|
|
hash := md5.Sum([]byte(conf.Server.Agents))
|
|
return fmt.Sprintf(
|
|
"%s.%t.%x",
|
|
a.cacheKey.Key(),
|
|
conf.Server.EnableExternalServices,
|
|
hash,
|
|
)
|
|
}
|
|
|
|
func (a *artistReader) LastUpdated() time.Time {
|
|
return a.lastUpdate
|
|
}
|
|
|
|
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
|
ff := []sourceFunc{a.fromArtistUploadedImage()}
|
|
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
|
|
return selectImageReader(ctx, a.artID, ff...)
|
|
}
|
|
|
|
func (a *artistReader) fromArtistUploadedImage() sourceFunc {
|
|
return fromLocalFile(a.artist.UploadedImagePath())
|
|
}
|
|
|
|
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
|
var ff []sourceFunc
|
|
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
|
pattern = strings.TrimSpace(pattern)
|
|
switch {
|
|
case pattern == "external":
|
|
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
|
case pattern == "image-folder":
|
|
ff = append(ff, a.fromArtistImageFolder(ctx))
|
|
case strings.HasPrefix(pattern, "album/"):
|
|
if a.lib.FS != nil {
|
|
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
|
}
|
|
default:
|
|
ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
|
|
}
|
|
}
|
|
return ff
|
|
}
|
|
|
|
// fromArtistFolder walks up from artistFolder toward libPath looking for a
|
|
// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
|
|
// and the library root: once we reach libPath (or if artistFolder is outside
|
|
// libPath), the walk stops. All reads go through libFS, which keeps artwork
|
|
// resolution scoped to the configured library.
|
|
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
|
|
return func() (io.ReadCloser, string, error) {
|
|
if libFS == nil {
|
|
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
|
}
|
|
rel, err := filepath.Rel(libPath, artistFolder)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
|
}
|
|
// fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
|
|
// return backslash separators on Windows.
|
|
rel = filepath.ToSlash(rel)
|
|
current := artistFolder
|
|
for range maxArtistFolderTraversalDepth {
|
|
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
|
|
if err == nil {
|
|
return reader, hit, nil
|
|
}
|
|
if rel == "." {
|
|
break // reached library root; don't traverse above it
|
|
}
|
|
rel = path.Dir(rel)
|
|
current = filepath.Dir(current)
|
|
}
|
|
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
|
|
}
|
|
}
|
|
|
|
// findImageInFolder globs libFS at relFolder for pattern and returns the first
|
|
// matching image. absFolder is used only for the returned display path and log
|
|
// messages so callers see absolute-looking paths consistent with the rest of
|
|
// the artwork pipeline.
|
|
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
|
|
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
|
|
globPattern := pattern
|
|
if relFolder != "." {
|
|
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
|
|
}
|
|
matches, err := fs.Glob(libFS, globPattern)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
|
|
return nil, "", err
|
|
}
|
|
|
|
// Filter to valid image files
|
|
var imagePaths []string
|
|
for _, m := range matches {
|
|
if !model.IsImageFile(m) {
|
|
continue
|
|
}
|
|
imagePaths = append(imagePaths, m)
|
|
}
|
|
|
|
// Sort image files by prioritizing base filenames without numeric
|
|
// suffixes (e.g., artist.jpg before artist.1.jpg)
|
|
slices.SortFunc(imagePaths, compareImageFiles)
|
|
|
|
for _, p := range imagePaths {
|
|
f, err := libFS.Open(p)
|
|
if err != nil {
|
|
log.Warn(ctx, "Could not open cover art file", "file", p, err)
|
|
continue
|
|
}
|
|
_, name := path.Split(p)
|
|
return f, filepath.Join(absFolder, name), nil
|
|
}
|
|
|
|
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
|
|
}
|
|
|
|
func escapeGlobLiteral(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
switch r {
|
|
case '\\', '*', '?', '[', ']':
|
|
b.WriteByte('\\')
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// loadArtistAlbumRoots returns one path per album — the deepest folder holding
|
|
// all of that album's tracks — so an album split into disc subfolders can't
|
|
// pull the artist folder's common prefix below the artist level.
|
|
func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) {
|
|
var folderIDs []string
|
|
for _, album := range albums {
|
|
folderIDs = append(folderIDs, album.FolderIDs...)
|
|
}
|
|
folders, err := loadFolders(ctx, ds, folderIDs)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) {
|
|
return f.ID, f.AbsolutePath()
|
|
})
|
|
var roots []string
|
|
for _, album := range albums {
|
|
var albumPaths []string
|
|
for _, fid := range album.FolderIDs {
|
|
if p, ok := pathByID[fid]; ok {
|
|
albumPaths = append(albumPaths, p)
|
|
}
|
|
}
|
|
if len(albumPaths) > 0 {
|
|
roots = append(roots, commonDir(albumPaths))
|
|
}
|
|
}
|
|
|
|
imgFiles, updatedAt := folderImages(folders)
|
|
return roots, imgFiles, &updatedAt, nil
|
|
}
|
|
|
|
// commonDir returns the deepest directory containing all paths. Trailing
|
|
// separators keep the comparison on segment boundaries, so a shared name
|
|
// fragment (".../Album" and ".../Album2") is never read as a shared directory.
|
|
func commonDir(paths []string) string {
|
|
sep := string(filepath.Separator)
|
|
common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep }))
|
|
if !strings.HasSuffix(common, sep) {
|
|
common, _ = filepath.Split(common)
|
|
}
|
|
return filepath.Clean(common)
|
|
}
|
|
|
|
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
|
if len(albums) == 0 {
|
|
return "", time.Time{}, nil
|
|
}
|
|
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
|
|
|
|
// paths holds one root per album: two or more distinct roots already meet at
|
|
// the artist folder, while a single root is an album folder needing a climb.
|
|
roots := slices.Compact(slices.Sorted(slices.Values(paths)))
|
|
folderPath := commonDir(roots)
|
|
if len(roots) < 2 {
|
|
folderPath = filepath.Dir(folderPath)
|
|
}
|
|
|
|
// Manipulate the path to get the folder ID
|
|
// TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM
|
|
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
|
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
|
|
|
log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
|
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
|
|
|
// Get the last update time for the folder
|
|
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
|
if err != nil || len(folders) == 0 {
|
|
log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
|
"libPath", libPath, "libID", libID, err)
|
|
return "", time.Time{}, err
|
|
}
|
|
return folderPath, folders[0].ImagesUpdatedAt, nil
|
|
}
|
|
|
|
func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
|
|
return func() (io.ReadCloser, string, error) {
|
|
folder := conf.Server.ArtistImageFolder
|
|
if folder == "" {
|
|
return nil, "", nil
|
|
}
|
|
// Use cached path from newArtistArtworkReader if available,
|
|
// avoiding a second directory scan.
|
|
path := a.imgFolderImgPath
|
|
if path == "" {
|
|
path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
|
|
}
|
|
if path == "" {
|
|
return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return f, path, nil
|
|
}
|
|
}
|
|
|
|
// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
|
|
// (case-insensitive). Returns the full path, or empty string if not found.
|
|
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
|
entries, err := os.ReadDir(folder)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, candidate := range []string{mbzArtistID, artistName} {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
|
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
|
return filepath.Join(folder, name)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|