package e2e
import (
"image/color"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlist artwork resolution", func() {
BeforeEach(func() {
setupResolutionHarness()
})
When("a playlist has an uploaded image", func() {
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload"))
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("upload"))
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload")))
})
})
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
It("returns the sidecar image", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("folder"))
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar")))
})
})
When("a playlist's sidecar uses a different extension case", func() {
It("matches case-insensitively", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png")))
})
})
When("a playlist has an ExternalImageURL pointing to a local file", func() {
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
conf.Server.EnableM3UExternalAlbumArt = false
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local")))
})
})
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
It("skips the URL and settles absent", func() {
conf.Server.EnableM3UExternalAlbumArt = false
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Hash).To(BeEmpty())
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false)
Expect(err).ToNot(HaveOccurred())
defer img.Close()
Expect(img.Placeholder).To(BeTrue())
})
})
When("a playlist has no images and no tracks", func() {
It("settles absent", func() {
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Hash).To(BeEmpty())
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
})
})
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
It("generates a tiled cover from album art", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
"Artist/AlbumA/cover.png": smallPNG("albumA"),
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
"Artist/AlbumB/cover.png": smallPNG("albumB"),
})
scan()
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
data := storedBytes(ia)
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
q := gridQuadrants(data)
Expect(q[0]).To(Equal(q[3]))
Expect(q[1]).To(Equal(q[2]))
Expect(q[0]).ToNot(Equal(q[1]))
})
})
When("a playlist has tracks from four albums, each with its own cover", func() {
It("fills all four grid quadrants with distinct album art", func() {
conf.Server.CoverArtPriority = "cover.*"
layout := fstest.MapFS{}
for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} {
layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name})
layout["Artist/"+name+"/cover.png"] = smallPNG(name)
}
setLayout(layout)
scan()
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(4))
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
pl.AddMediaFilesByID(ids)
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
ia := acquire(model.KindPlaylistArtwork, pl.ID)
Expect(ia.Source).To(Equal("generated"))
q := gridQuadrants(storedBytes(ia))
Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4))
Expect(q[0]).ToNot(Equal(q[1]))
Expect(q[0]).ToNot(Equal(q[2]))
Expect(q[0]).ToNot(Equal(q[3]))
Expect(q[1]).ToNot(Equal(q[2]))
Expect(q[1]).ToNot(Equal(q[3]))
Expect(q[2]).ToNot(Equal(q[3]))
})
})
})
func putPlaylist(pl model.Playlist) model.Playlist {
GinkgoHelper()
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
return pl
}