* fix(artwork): include top-level album folders in parent cover art lookup The Path != "." guard added in #5451 was too aggressive — it excluded any folder with Path=".", which includes top-level album folders (not just the library root). Changed to ParentID != "" which correctly excludes only the actual library root folder. Fixes #5456 * fix: correct comment in test — album is under library root, not artist root * test: add ascii tree diagram to top-level album e2e test * test: replace internal bug references with issue link in e2e comments Signed-off-by: Deluan <deluan@navidrome.org> * test: add e2e test matching reporter's exact library layout (#5456) Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs using the reporter's actual folder names to verify artwork resolution works for non-top-level album folders too. * fix(scanner): use a syntectic admin user when no admin user is found Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache When Phase 3 corrects an album's FolderIDs (or any other field), bump UpdatedAt to the current time. This ensures the artwork cache key changes, invalidating any stale artwork that was resolved and cached during Phase 1 when the album had incomplete folder data. * fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt semantics) and instead includes album.ImportedAt in the artwork cache key computation. Since ImportedAt is bumped to time.Now() on every album Put, any Phase 3 correction naturally invalidates cached artwork that was resolved mid-scan with incomplete folder data. * fix(artwork): simplify lastUpdate logic using TimeNewest utility Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
377 lines
13 KiB
Go
377 lines
13 KiB
Go
package artworke2e_test
|
|
|
|
import (
|
|
"testing/fstest"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
const (
|
|
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
|
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
|
)
|
|
|
|
var _ = Describe("Album artwork resolution", func() {
|
|
BeforeEach(func() {
|
|
setupHarness()
|
|
})
|
|
|
|
When("an album has a single folder with cover.jpg at the album root", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// └── cover.jpg ← matched by cover.*
|
|
It("returns the album-root cover", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
|
})
|
|
})
|
|
|
|
// https://github.com/navidrome/navidrome/issues/5376
|
|
// cover.* basenames tie across album-root and per-disc folders;
|
|
// compareImageFiles must prefer shallower paths.
|
|
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── CD1/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── cover.jpg ← should not win
|
|
// ├── CD2/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── cover.jpg
|
|
// └── cover.jpg ← should win (album-root fallback)
|
|
It("prefers the album-root cover over per-disc covers", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
|
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
|
|
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(al.FolderIDs).To(HaveLen(2),
|
|
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
|
})
|
|
})
|
|
|
|
// https://github.com/navidrome/navidrome/issues/5376
|
|
// folder.jpg basenames tie across album-root and per-disc folders;
|
|
// compareImageFiles must prefer shallower paths.
|
|
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── CD1/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── folder.jpg ← should not win
|
|
// ├── CD2/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── folder.jpg
|
|
// └── folder.jpg ← should win (album-root fallback)
|
|
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
|
"Artist/Album/folder.jpg": imageFile("album-root"),
|
|
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
|
|
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
|
})
|
|
})
|
|
|
|
// https://github.com/navidrome/navidrome/issues/5376
|
|
// Single-subfolder albums must still consider the parent folder's images.
|
|
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── disc1/
|
|
// │ └── 01 - Track.mp3
|
|
// └── cover.jpg ← should win (parent-folder fallback)
|
|
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
|
})
|
|
})
|
|
|
|
// https://github.com/navidrome/navidrome/issues/5456
|
|
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
|
|
// Album/ (top-level folder, Path=".")
|
|
// ├── CD1/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── folder.jpg
|
|
// ├── CD2/
|
|
// │ ├── 01 - Track.mp3
|
|
// │ └── folder.jpg
|
|
// └── cover.jpg ← should win (album-root)
|
|
It("prefers the album-root cover.jpg", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
|
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
|
"Album/cover.jpg": imageFile("album-root"),
|
|
"Album/CD1/folder.jpg": imageFile("disc1"),
|
|
"Album/CD2/folder.jpg": imageFile("disc2"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
|
})
|
|
})
|
|
|
|
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
|
// └── cover.jpg
|
|
It("returns the embedded image", func() {
|
|
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
|
"Artist/Album/cover.jpg": imageFile("external"),
|
|
})
|
|
scan()
|
|
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
|
|
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
|
})
|
|
})
|
|
|
|
When("CoverArtPriority lists external first but no external file is present", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
|
It("falls through to embedded artwork", func() {
|
|
conf.Server.CoverArtPriority = "external, embedded"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
|
})
|
|
scan()
|
|
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
|
})
|
|
})
|
|
|
|
When("the only cover file uses uppercase extension and a different case in its name", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// └── Cover.JPG ← matched case-insensitively by cover.*
|
|
It("matches case-insensitively against cover.*", func() {
|
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
|
|
})
|
|
})
|
|
|
|
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// ├── cover.jpg ← wins (no numeric suffix)
|
|
// └── cover.1.jpg
|
|
It("prefers the file without a numeric suffix", func() {
|
|
conf.Server.CoverArtPriority = "cover.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("primary"),
|
|
"Artist/Album/cover.1.jpg": imageFile("secondary"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
|
})
|
|
})
|
|
|
|
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
|
|
It("returns ErrUnavailable", func() {
|
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
// Doc scenarios from:
|
|
// https://www.navidrome.org/docs/usage/library/artwork/#albums
|
|
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
|
|
When("only folder.jpg is present (cover.* and front.* missing)", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// └── folder.jpg ← matched by folder.*
|
|
It("falls through to folder.jpg", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
|
})
|
|
})
|
|
|
|
When("only front.jpg is present (cover.* and folder.* missing)", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// └── front.jpg ← matched by front.*
|
|
It("falls through to front.jpg", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/front.jpg": imageFile("front"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
|
|
})
|
|
})
|
|
|
|
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// ├── cover.jpg ← wins (cover.* is first in priority)
|
|
// ├── folder.jpg
|
|
// └── front.jpg
|
|
It("prefers cover.* (first in CoverArtPriority)", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
|
"Artist/Album/front.jpg": imageFile("front"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
|
})
|
|
})
|
|
|
|
When("only folder.* and front.* exist (priority order check)", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
|
// └── front.jpg
|
|
It("prefers folder.* over front.*", func() {
|
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
|
"Artist/Album/front.jpg": imageFile("front"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
|
})
|
|
})
|
|
|
|
When("three cover files tie by basename and differ only by numeric suffix", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// ├── cover.jpg ← wins (no numeric suffix)
|
|
// ├── cover.1.jpg
|
|
// └── cover.2.jpg
|
|
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
|
conf.Server.CoverArtPriority = "cover.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.2.jpg": imageFile("second"),
|
|
"Artist/Album/cover.jpg": imageFile("primary"),
|
|
"Artist/Album/cover.1.jpg": imageFile("first"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
|
})
|
|
})
|
|
|
|
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3
|
|
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
|
It("skips the unknown pattern and falls through to the matching one", func() {
|
|
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
|
})
|
|
})
|
|
|
|
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
|
// Artist/
|
|
// └── Album/
|
|
// ├── 01 - Track.mp3 (no embedded picture)
|
|
// └── cover.jpg ← wins (embedded skipped, falls through)
|
|
It("falls through to the next priority entry", func() {
|
|
conf.Server.CoverArtPriority = "embedded, cover.*"
|
|
setLayout(fstest.MapFS{
|
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
|
})
|
|
scan()
|
|
|
|
al := firstAlbum()
|
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
|
})
|
|
})
|
|
})
|