mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-02 16:37:49 +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>
236 lines
7.1 KiB
Go
236 lines
7.1 KiB
Go
package artwork
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"crypto/md5"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"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/natural"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
type albumArtworkReader struct {
|
|
cacheKey
|
|
a *artwork
|
|
provider external.Provider
|
|
album model.Album
|
|
updatedAt *time.Time
|
|
imgFiles []string // library-relative, forward-slash, no leading slash
|
|
lib libraryView
|
|
}
|
|
|
|
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
|
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := &albumArtworkReader{
|
|
a: artwork,
|
|
provider: provider,
|
|
album: *al,
|
|
updatedAt: imagesUpdateAt,
|
|
imgFiles: imgFiles,
|
|
lib: lib,
|
|
}
|
|
a.cacheKey.artID = artID
|
|
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
|
|
if imagesUpdateAt != nil {
|
|
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (a *albumArtworkReader) Key() string {
|
|
hashInput := conf.Server.CoverArtPriority
|
|
if conf.Server.EnableExternalServices {
|
|
hashInput = conf.Server.Agents + hashInput
|
|
}
|
|
hash := md5.Sum([]byte(hashInput))
|
|
return fmt.Sprintf(
|
|
"%s.%x.%t",
|
|
a.cacheKey.Key(),
|
|
hash,
|
|
conf.Server.EnableExternalServices,
|
|
)
|
|
}
|
|
func (a *albumArtworkReader) LastUpdated() time.Time {
|
|
return a.lastUpdate
|
|
}
|
|
|
|
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
|
var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
|
|
return selectImageReader(ctx, a.artID, ff...)
|
|
}
|
|
|
|
func (a *albumArtworkReader) fromCoverArtPriority(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":
|
|
embedRel := a.album.EmbedArtPath
|
|
ff = append(ff,
|
|
fromTag(ctx, a.lib.FS, embedRel),
|
|
fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
|
|
)
|
|
case pattern == "external":
|
|
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
|
case len(a.imgFiles) > 0:
|
|
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
|
|
}
|
|
}
|
|
return ff
|
|
}
|
|
|
|
func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) {
|
|
folders, err := loadFolders(ctx, ds, album.FolderIDs)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if parent != nil {
|
|
folders = append(folders, *parent)
|
|
}
|
|
|
|
paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() })
|
|
imgFiles, updatedAt := folderImages(folders)
|
|
return paths, imgFiles, &updatedAt, nil
|
|
}
|
|
|
|
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
|
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
|
}
|
|
|
|
// folderImages collects the folders' image files, sorted so files without
|
|
// numeric suffixes win (e.g. cover.jpg over cover.1.jpg).
|
|
func folderImages(folders []model.Folder) ([]string, time.Time) {
|
|
var imgFiles []string
|
|
var updatedAt time.Time
|
|
for _, f := range folders {
|
|
if f.ImagesUpdatedAt.After(updatedAt) {
|
|
updatedAt = f.ImagesUpdatedAt
|
|
}
|
|
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
|
|
for _, img := range f.ImageFiles {
|
|
imgFiles = append(imgFiles, path.Join(rel, img))
|
|
}
|
|
}
|
|
slices.SortFunc(imgFiles, compareImageFiles)
|
|
return imgFiles, updatedAt
|
|
}
|
|
|
|
// albumRootParent returns the common parent of the album's folders when it
|
|
// qualifies as the album's root folder (e.g. "Artist/Album" above disc
|
|
// subfolders), or nil when there is no such parent. This finds cover art in
|
|
// the album root folder when tracks live in disc subfolders, like
|
|
// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and
|
|
// "Artist/Album/CD2/". The parent must look like an album root, not an
|
|
// artist-level folder — it qualifies only when it holds no audio belonging to
|
|
// other albums — so artist images are never served as album art.
|
|
func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) {
|
|
folderIDSet := make(map[string]bool, len(folderIDs))
|
|
for _, id := range folderIDs {
|
|
folderIDSet[id] = true
|
|
}
|
|
commonParentID := commonParentFolder(folders, folderIDSet)
|
|
if commonParentID == "" {
|
|
return nil, nil
|
|
}
|
|
// Single-folder albums only use the parent when the folder has no images
|
|
// of its own (indicating a disc subfolder needing parent artwork).
|
|
if len(folders) < 2 && anyFolderHasImages(folders) {
|
|
return nil, nil
|
|
}
|
|
parent, err := ds.Folder(ctx).Get(commonParentID)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parent.ParentID == "" {
|
|
// The library root can never be an album root
|
|
return nil, nil
|
|
}
|
|
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hasOtherAudio {
|
|
return nil, nil
|
|
}
|
|
return parent, nil
|
|
}
|
|
|
|
func anyFolderHasImages(folders []model.Folder) bool {
|
|
for _, f := range folders {
|
|
if len(f.ImageFiles) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// commonParentFolder returns the shared parent folder ID when all folders have the
|
|
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
|
|
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
|
|
if len(folders) == 0 {
|
|
return ""
|
|
}
|
|
parentID := folders[0].ParentID
|
|
if parentID == "" || folderIDSet[parentID] {
|
|
return ""
|
|
}
|
|
for _, f := range folders[1:] {
|
|
if f.ParentID != parentID {
|
|
return ""
|
|
}
|
|
}
|
|
return parentID
|
|
}
|
|
|
|
// compareImageFiles sorts image paths by: base filename (natural order),
|
|
// then path depth (shallower first), then full path (stable tiebreaker).
|
|
func compareImageFiles(a, b string) int {
|
|
// Case-insensitive comparison
|
|
a = strings.ToLower(a)
|
|
b = strings.ToLower(b)
|
|
|
|
// Extract base filenames without extensions
|
|
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
|
|
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
|
|
|
|
// Compare base names first, then prefer shallower paths, then full path as tiebreaker
|
|
return cmp.Or(
|
|
natural.Compare(baseA, baseB),
|
|
cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
|
|
natural.Compare(a, b),
|
|
)
|
|
}
|