mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* test(artwork): add failing e2e tests for artist image leaking as album art Reproduces a v0.62.0 regression (#5451/#5457): the album cover-art parent-folder fallback can include the artist folder, serving the artist thumbnail (e.g. Artist/folder.jpg) as album art for any album without image files in its own folder(s). Covers three scenarios: a plain Artist/Album layout with no album images, a single-disc album spread across sibling folders under the artist folder, and a spread album whose own front.jpg is shadowed by the artist's cover.jpg via CoverArtPriority order. Also adds an albumByName test helper for multi-album layouts. The tests are expected to fail until the parent-folder inclusion is gated by a structural check (skip the common parent when audio from other albums lives under it). * fix(artwork): never serve artist folder images as album art The album cover-art parent-folder fallback (introduced in #5451/#5457) could include the artist folder as a source of album images, serving the artist thumbnail (e.g. Artist/folder.jpg) as cover art for any album without image files in its own folder(s). This affected both plain Artist/Album layouts and single-disc albums spread across sibling folders under the artist folder. Gate the common-parent inclusion with a structural check: the parent only qualifies as an album root when no audio belonging to other albums lives in it or anywhere beneath it. An artist folder contains other albums' tracks, while an album root above disc subfolders contains only this album's, so the check works for any disc folder naming scheme and never affects the multi-disc fixes from #5376/#5456. A single-album artist with no images anywhere remains structurally indistinguishable from an album root and is a known residual case. * refactor(artwork): move album-root audio check into folder repository Replace the raw subtree SQL (LIKE/ESCAPE expression and wildcard escaping) that lived in core/artwork with an explicit FolderRepository.HasAudioOutsideFolders method, implemented in the persistence layer next to the existing folder-subtree query pattern. This also removes the test mock's brittle dispatch that sniffed the generated SQL to recognize the query; the fake now overrides the new method directly. Extract the whole parent-folder resolution from loadAlbumFoldersPaths into an albumRootParent helper, flattening four levels of nesting back into a linear flow. Behavior is unchanged; the unit test for a parent containing audio moved to the persistence suite, with added coverage for subtree boundaries, missing folders, and LIKE-wildcard escaping in folder paths. * refactor(persistence): use exists helper in HasAudioOutsideFolders Replace the hand-rolled count(*) query with the repository's canonical exists helper, as suggested in PR review.
235 lines
6.9 KiB
Go
235 lines
6.9 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"
|
|
)
|
|
|
|
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, albums ...model.Album) ([]string, []string, *time.Time, error) {
|
|
var folderIDs []string
|
|
for _, album := range albums {
|
|
folderIDs = append(folderIDs, album.FolderIDs...)
|
|
}
|
|
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if parent != nil {
|
|
folders = append(folders, *parent)
|
|
}
|
|
|
|
var paths []string
|
|
var imgFiles []string
|
|
var updatedAt time.Time
|
|
for _, f := range folders {
|
|
paths = append(paths, f.AbsolutePath())
|
|
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))
|
|
}
|
|
}
|
|
|
|
// Sort image files to ensure consistent selection of cover art
|
|
// This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
|
|
// by comparing base filenames without extensions
|
|
slices.SortFunc(imgFiles, compareImageFiles)
|
|
|
|
return paths, imgFiles, &updatedAt, nil
|
|
}
|
|
|
|
// 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),
|
|
)
|
|
}
|