package e2e import ( "os" "path/filepath" "testing/fstest" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) // Doc reference: // https://www.navidrome.org/docs/usage/library/artwork/#artists // Default ArtistArtPriority is "artist.*, album/artist.*, external". // Library-folder images are file-backed (asserted on the worker state row); uploaded and // image-folder images are real files on disk (asserted byte-for-byte). var _ = Describe("Artist artwork resolution", func() { BeforeEach(func() { setupResolutionHarness() }) When("the artist folder contains an artist.jpg", func() { // Artist/ // ├── artist.jpg ← matched by artist.* // └── Album/ // └── 01 - Track.mp3 It("returns the artist.* image from the artist folder", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("artist.* only exists inside an album folder", func() { // Artist/ // └── Album/ // ├── 01 - Track.mp3 // └── artist.jpg ← matched by album/artist.* It("falls through to album/artist.* and returns that image", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") }) }) When("both the artist folder and an album folder have an artist.* image", func() { // Artist/ // ├── artist.jpg ← wins (artist.* before album/artist.*) // └── Album/ // ├── 01 - Track.mp3 // └── artist.jpg It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/artist.jpg": smallPNG("artist-folder"), "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("ArtistArtPriority has no album/ fallback", func() { // Artist/ // ├── artist.jpg ← must resolve via the artist folder itself // └── Album/ // └── 01 - Track.mp3 It("still resolves the artist folder and returns artist.*", func() { conf.Server.ArtistArtPriority = "artist.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("the artist's only album folder has no images of its own", func() { // Artist/ // ├── backdrop1.jpg // ├── folder.jpg ← matched by folder.* // ├── logo.png // └── Album/ // ├── 01 - Track.mp3 // └── 02 - Track.mp3 It("resolves the artist folder, not the library root", func() { conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/backdrop1.jpg": smallPNG("backdrop"), "Artist/folder.jpg": smallPNG("artist-folder"), "Artist/logo.png": smallPNG("logo"), }) scan() expectArtistFolder(soleArtist(), "Artist/folder.jpg") }) }) When("the artist's only album has its tracks in disc subfolders", func() { // Artist/ // ├── artist.jpg ← wins (artist.* before album/artist.*) // └── Album/ // ├── artist.jpg // ├── CD1/01 - Track.mp3 // └── CD2/02 - Track.mp3 It("prefers the artist-folder image over the album-folder one", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/artist.jpg": smallPNG("artist-folder"), "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("one album has disc subfolders and another sits at artist level", func() { // Artist/ // ├── artist.jpg ← wins // ├── Album1/ // │ ├── artist.jpg // │ ├── CD1/01 - Track.mp3 // │ └── CD2/02 - Track.mp3 // └── Album2/03 - Track.mp3 It("prefers the artist-folder image over the album-folder one", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), "Artist/artist.jpg": smallPNG("artist-folder"), "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("every album of the artist has its tracks in disc subfolders", func() { // Artist/ // ├── artist.jpg ← wins // ├── Album1/ // │ ├── artist.jpg // │ ├── CD1/01 - Track.mp3 // │ └── CD2/02 - Track.mp3 // └── Album2/ // ├── CD1/03 - Track.mp3 // └── CD2/04 - Track.mp3 It("prefers the artist-folder image over the album-folder one", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), "Artist/artist.jpg": smallPNG("artist-folder"), "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) When("an artist has an uploaded image and a matching artist.* file", func() { // / // └── artwork/ // └── artist/ // └── _upload.jpg ← wins (uploaded image beats the priority chain) // Library: // Artist/ // ├── artist.jpg (ignored — uploaded image comes first) // └── Album/ // └── 01 - Track.mp3 It("prefers the uploaded image over any priority-chain match", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() ar := soleArtist() uploaded := ar.ID + "_upload.jpg" writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) ar.UploadedImage = uploaded Expect(rds.Artist().Put(rctx, &ar)).To(Succeed()) ia := acquire(model.KindArtistArtwork, ar.ID) Expect(ia.Source).To(Equal("upload")) Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded"))) }) }) When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { // Artist/ // └── Album/ // ├── 01 - Track.mp3 // └── artist.jpg ← matched by album/artist.* It("resolves the pattern against the artist's album image files", func() { conf.Server.ArtistArtPriority = "album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") }) }) // resolveArtist only samples albums where this artist is the SOLE album artist, so a // collaboration or compilation never donates its images as the artist's own. When("the artist's only album is credited to two album artists", func() { // Artist/ // └── Collab Album/ (album artists: "Artist" + a collaborator) // ├── 01 - Track.mp3 // └── artist.jpg ← must NOT become the artist image It("ignores the album's images and settles absent", func() { conf.Server.ArtistArtPriority = "album/artist.*" // " / " is a default artists split separator, so this single tag yields two album artists. setLayout(fstest.MapFS{ "Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}), "Artist/Collab Album/artist.jpg": smallPNG("collab-artist"), }) scan() Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2), "sanity check: the album must be credited to two album artists") ar := soleArtist() ia := acquire(model.KindArtistArtwork, ar.ID) Expect(ia.Hash).To(BeEmpty()) Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))). To(MatchError(artwork.ErrUnavailable)) }) }) When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { // / // └── Artist.jpg ← matched by artist name (image-folder source) // Library: // Artist/ // └── Album/ // └── 01 - Track.mp3 (no artist.* present in library) It("returns the image from the configured artist image folder", func() { imgFolder := GinkgoT().TempDir() Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed()) conf.Server.ArtistImageFolder = imgFolder conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), }) scan() ar := soleArtist() ia := acquire(model.KindArtistArtwork, ar.ID) Expect(ia.Source).To(Equal("folder")) Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder"))) }) }) }) func soleArtist() model.Artist { GinkgoHelper() artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{ Filters: squirrel.Eq{"artist.name": "Artist"}, }) Expect(err).ToNot(HaveOccurred()) if len(artists) == 0 { Fail("sole artist not found") return model.Artist{} } return artists[0] }