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"
)
var _ = Describe("Artist artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
})
When("the artist folder contains an artist.jpg", func() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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/<arbitrary pattern> (not just album/artist.*)", func() {
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")
})
})
When("the artist's only album is credited to two album artists", func() {
It("ignores the album's images and settles absent", func() {
conf.Server.ArtistArtPriority = "album/artist.*"
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() {
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]
}