package e2e import ( "testing/fstest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) // The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so // folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes. var _ = Describe("Album artwork resolution", func() { BeforeEach(func() { setupResolutionHarness() }) 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": smallPNG("album-root"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) // 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": smallPNG("album-root"), "Artist/Album/CD1/cover.jpg": smallPNG("disc1"), "Artist/Album/CD2/cover.jpg": smallPNG("disc2"), }) scan() al := firstAlbum() Expect(al.FolderIDs).To(HaveLen(2), "sanity check: the two disc subfolders should form one multi-disc album") expectAlbumFolderCover(al, "Artist/Album/cover.jpg") }) }) // 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": smallPNG("album-root"), "Artist/Album/CD1/folder.jpg": smallPNG("disc1"), "Artist/Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) // 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": smallPNG("album-root"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) // 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": smallPNG("album-root"), "Album/CD1/folder.jpg": smallPNG("disc1"), "Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg") }) }) 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": smallPNG("external"), }) scan() replaceWithRealMP3("Artist/Album/01 - Track.mp3") ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) Expect(ia.Source).To(Equal("embedded")) Expect(storedBytes(ia)).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") ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) Expect(ia.Source).To(Equal("embedded")) Expect(storedBytes(ia)).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": smallPNG("case-insensitive"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG") }) }) 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": smallPNG("primary"), "Artist/Album/cover.1.jpg": smallPNG("secondary"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) When("the album has no cover and CoverArtPriority lists only file patterns", func() { // Artist/ // └── Album/ // └── 01 - Track.mp3 (no image files — settles absent) It("settles absent", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() expectAlbumAbsent(firstAlbum()) }) }) // 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": smallPNG("folder"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) 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": smallPNG("front"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg") }) }) 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": smallPNG("cover"), "Artist/Album/folder.jpg": smallPNG("folder"), "Artist/Album/front.jpg": smallPNG("front"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) 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": smallPNG("folder"), "Artist/Album/front.jpg": smallPNG("front"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) 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": smallPNG("second"), "Artist/Album/cover.jpg": smallPNG("primary"), "Artist/Album/cover.1.jpg": smallPNG("first"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) 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": smallPNG("cover"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder // fallback can pick up images from the ARTIST folder, serving the artist // thumbnail as album art for any album without its own image files. When("an album has no images and the artist folder has folder.jpg", func() { // Artist/ // ├── folder.jpg ← artist thumbnail, must NOT become album art // ├── Album A/ // │ └── 01 - Track.mp3 (no images) // └── Album B/ // ├── 01 - Track.mp3 // └── cover.jpg It("does not use the artist image as album art", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() // Album B first: the acquire in expectAlbumAbsent would settle Album B too. expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg") expectAlbumAbsent(albumByName("Album A")) }) }) When("a single-disc album is spread across sibling folders under the artist folder", func() { // Artist/ // ├── folder.jpg ← artist thumbnail, must NOT become album art // ├── Album A/ // │ └── 01 - Track.mp3 (album: "Album A") // ├── Album A bonus/ // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) // └── Album B/ // ├── 01 - Track.mp3 // └── cover.jpg It("does not use the artist image as album art for the spread album", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), "sanity check: the two sibling folders should form one spread album") expectAlbumAbsent(alA) }) }) // albumRootParent refuses the library root as an album root (parent.ParentID == ""). When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() { // (library root) // ├── cover.jpg ← must NOT be adopted // ├── CD1/ // │ └── 01 - Track.mp3 // └── CD2/ // └── 01 - Track.mp3 It("does not adopt the library-root image as album art", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "cover.jpg": smallPNG("library-root"), "CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}), "CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}), }) scan() expectAlbumAbsent(firstAlbum()) }) }) // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips // the parent folder for a single-folder album that has images of its own. When("a single-folder album has its own cover.jpg and the artist folder has one too", func() { // Artist/ // ├── cover.jpg ← shallower, but must NOT win // └── Album/ // ├── 01 - Track.mp3 // └── cover.jpg ← should win It("prefers the album's own cover over the shallower artist-folder cover", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/cover.jpg": smallPNG("artist-image"), "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/Album/cover.jpg": smallPNG("album-own"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { // Artist/ // ├── cover.jpg ← artist image; matches cover.* (first pattern), // │ must NOT shadow the album's own front.jpg // ├── Album A/ // │ ├── 01 - Track.mp3 (album: "Album A") // │ └── front.jpg ← should win // ├── Album A bonus/ // │ └── 02 - Track.mp3 (album: "Album A") // └── Album B/ // └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root) It("prefers the album's own art over the artist image", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/cover.jpg": smallPNG("artist-image"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album A/front.jpg": smallPNG("album-a-front"), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), }) scan() alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), "sanity check: the two sibling folders should form one spread album") expectAlbumFolderCover(alA, "Artist/Album A/front.jpg") }) }) 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": smallPNG("cover"), }) scan() expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) })