mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 11:27:11 +02:00
* fix(playlists): limit local cover paths to images in owner's libraries A local #EXTALBUMARTURL path (absolute or file://) was only checked against the union of all libraries. The artwork resolver then opened it with no further check and served the bytes as the playlist cover, undecoded. Any user who can upload an M3U could read any file under any library root, including libraries they were not granted, through getCoverArt (GHSA-vwq6-xrw5-phpg). resolveImageURL now requires an image extension, and for uploaded playlists (no folder) the library holding the cover must pass the owner's HasLibraryAccess. Scanner and CLI imports keep the all-libraries check, since those files are admin-controlled. resolveLocalFile, used by every file-backed artwork source, now ignores paths without an image extension, which covers playlists stored before this fix that were not resolved yet. openOriginal refuses a stored file-backed row whose path is not an image, so the existing dangling path re-resolves it and the playlist falls back to the generated grid. No migration is needed. * fix(artwork): skip non-image files matched by folder cover patterns Album and disc folder sources opened any file in the folder's image list that matched a cover pattern, without checking its extension. openOriginal now refuses to serve file-backed rows whose path is not an image, so a stored row like that would be refused, re-resolved to the same file, and refused again on every view. The list comes from the scanner, which only records image files, but a database scanned where the OS mime table knows more image types than the serving process could still reach this. Both fromExternalFile variants now skip matches that are not image files, so the album falls back to its next source instead. Also correct the parser comment: a playlist without a folder can come from an API upload or from a CLI import of a file outside all libraries. * fix(artwork): check stored source type before using the resize cache The image-extension check for file-backed rows ran inside openOriginal, which the resize cache skips on a hit. Before the fix, a resized request for a playlist pointing at a non-image file cached the raw bytes, because a failed resize falls back to the original data. After the upgrade the same request still hit that entry and returned the file. serveHash now refuses a file-backed row whose path is not an image before calling serveSource, so both full-size and resized requests go through dangling and re-resolve the item. The stale cache entry is keyed by the old hash and is no longer reachable once the row changes. * test: register mime_types.yaml in test binaries Artwork resolution now skips candidates that are not image files, and model.IsImageFile answers from the process mime table. The server registers the extra image types from resources/mime_types.yaml through a conf hook, but a test binary only does that if it links conf/mime, so the artwork e2e suite fell back to the host table: .jxl resolves on macOS and Linux and does not on Windows, where the #5950 cover spec then found no source. tests.Init now imports conf/mime for its side effect, so every suite that loads the test config sees the same image types as the server. * fix(artwork): drop the image-file guard from the disc art reader The guard was added to both fromExternalFile variants, but disc artwork keeps no state row and is never queued, so it cannot hit the refuse-and-re-resolve loop the guard exists to prevent. The only case where it can fire is a real image whose extension this process's mime table does not know, and there it drops a disc cover that used to work. The album variant keeps the guard, since those resolutions are stored and re-served.
834 lines
35 KiB
Go
834 lines
35 KiB
Go
package artwork
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("IsArtistImageFile", func() {
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
})
|
|
|
|
It("matches bare and album/-prefixed glob tokens, case-insensitively", func() {
|
|
conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external"
|
|
Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue())
|
|
Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue())
|
|
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
|
|
})
|
|
|
|
It("matches a directory-bearing glob by its basename", func() {
|
|
conf.Server.ArtistArtPriority = "images/artist.*, external"
|
|
Expect(IsArtistImageFile("artist.jpg")).To(BeTrue())
|
|
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
|
|
})
|
|
|
|
It("does not treat non-file tokens as globs", func() {
|
|
conf.Server.ArtistArtPriority = "image-folder, external"
|
|
Expect(IsArtistImageFile("image-folder")).To(BeFalse())
|
|
Expect(IsArtistImageFile("external")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("resolveItem", func() {
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
folderRepo *fakeFolderRepo
|
|
libRepo *tests.MockLibraryRepo
|
|
ffm *tests.MockFFmpeg
|
|
ag *agents.Agents
|
|
repoRoot string
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
ctx = context.Background()
|
|
var err error
|
|
repoRoot, err = os.Getwd()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
folderRepo = &fakeFolderRepo{}
|
|
libRepo = &tests.MockLibraryRepo{}
|
|
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
|
ffm = tests.NewMockFFmpeg("")
|
|
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
|
|
ds = &tests.MockDataStore{
|
|
MockedFolder: folderRepo,
|
|
MockedLibrary: libRepo,
|
|
}
|
|
})
|
|
|
|
Describe("kind dispatch", func() {
|
|
It("returns an error for kinds the worker never enqueues", func() {
|
|
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"})
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Describe("media file", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.EnableMediaFileCoverArt = true
|
|
ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
|
|
})
|
|
|
|
It("resolves embedded art from the track file", func() {
|
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
|
})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("embedded"))
|
|
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
|
Expect(res.refMtime).To(BeNumerically(">", 0))
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("resolves absent when the track has no cover art", func() {
|
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false},
|
|
})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("resolves absent when media file cover art is disabled", func() {
|
|
conf.Server.EnableMediaFileCoverArt = false
|
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
|
})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
})
|
|
|
|
It("returns the error when the track is not in the DB", func() {
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"})
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
Expect(res.reader).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("album", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
|
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
|
})
|
|
|
|
It("resolves folder art from the library FS", func() {
|
|
folderRepo.result = []model.Folder{{
|
|
Path: "tests/fixtures/artist/an-album",
|
|
ImageFiles: []string{"cover.jpg"},
|
|
}}
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
|
})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
|
Expect(res.refMtime).To(BeNumerically(">", 0))
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("falls back to embedded art when no folder image matches", func() {
|
|
folderRepo.result = nil
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
|
})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("embedded"))
|
|
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
|
Expect(res.refMtime).To(BeNumerically(">", 0))
|
|
})
|
|
|
|
It("sets extErr when the external source errors without being not-found", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al3", Name: "Album"},
|
|
})
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
})
|
|
|
|
It("does not set extErr when the external source reports not-found", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al4", Name: "Album"},
|
|
})
|
|
// no image agents enabled -> the external step is a definitive not-found
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("carries extErr onto a fallback folder hit after a transient external failure", func() {
|
|
conf.Server.CoverArtPriority = "external, cover.jpg"
|
|
folderRepo.result = []model.Folder{{
|
|
Path: "tests/fixtures/artist/an-album",
|
|
ImageFiles: []string{"cover.jpg"},
|
|
}}
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al6", Name: "Album", FolderIDs: []string{"f1"}},
|
|
})
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
})
|
|
|
|
It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() {
|
|
conf.Server.CoverArtPriority = "external, cover.jpg"
|
|
folderRepo.result = []model.Folder{{
|
|
Path: "tests/fixtures/artist/an-album",
|
|
ImageFiles: []string{"cover.jpg"},
|
|
}}
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al7", Name: "Album", FolderIDs: []string{"f1"}},
|
|
})
|
|
// no image agents enabled -> the external step is a definitive not-found
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("routes the external step through the injected gate, keyed by agent name", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al5", Name: "Album"},
|
|
})
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
|
var gatedNames []string
|
|
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
gatedNames = append(gatedNames, name)
|
|
return f()
|
|
}
|
|
|
|
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
Expect(gatedNames).To(Equal([]string{"failAgent"}))
|
|
})
|
|
})
|
|
|
|
Describe("artist", func() {
|
|
It("resolves the uploaded image before any priority chain lookup", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
|
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed())
|
|
imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
|
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
|
|
ds.MockedArtist = artistRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("upload"))
|
|
Expect(res.sourcePath).To(Equal(imgPath))
|
|
})
|
|
|
|
It("falls through to the ArtistArtPriority chain when there is no upload", func() {
|
|
conf.Server.ArtistArtPriority = "album/artist.*"
|
|
folderRepo.result = []model.Folder{{
|
|
LibraryPath: testFileLibPath(repoRoot),
|
|
Path: "tests/fixtures/artist/an-album",
|
|
ImageFiles: []string{"artist.png"},
|
|
}}
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
|
|
ds.MockedArtist = artistRepo
|
|
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{
|
|
{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}},
|
|
}
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
|
|
})
|
|
|
|
It("sets extErr when the external source errors without being not-found", func() {
|
|
conf.Server.ArtistArtPriority = "external"
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}})
|
|
ds.MockedArtist = artistRepo
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
})
|
|
|
|
It("does not set extErr when the external source reports not-found", func() {
|
|
conf.Server.ArtistArtPriority = "external"
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
|
|
ds.MockedArtist = artistRepo
|
|
// no image agents enabled -> the external step is a definitive not-found
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("routes the external step through the injected gate, keyed by agent name", func() {
|
|
conf.Server.ArtistArtPriority = "external"
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
|
|
ds.MockedArtist = artistRepo
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
|
var gatedNames []string
|
|
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
gatedNames = append(gatedNames, name)
|
|
return f()
|
|
}
|
|
|
|
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
Expect(gatedNames).To(Equal([]string{"failAgent"}))
|
|
})
|
|
})
|
|
|
|
Describe("radio", func() {
|
|
It("yields an empty resolution when there is no uploaded image", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
|
|
|
radioRepo := tests.CreateMockedRadioRepo()
|
|
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}}
|
|
ds.MockedRadio = radioRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res).To(Equal(resolution{}))
|
|
})
|
|
|
|
It("resolves the uploaded image when set", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
|
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
|
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
|
|
|
radioRepo := tests.CreateMockedRadioRepo()
|
|
radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}}
|
|
ds.MockedRadio = radioRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("upload"))
|
|
Expect(res.sourcePath).To(Equal(imgPath))
|
|
})
|
|
})
|
|
|
|
Describe("playlist", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.CoverArtPriority = "cover.jpg"
|
|
folderRepo.result = []model.Folder{{
|
|
Path: "tests/fixtures/artist/an-album",
|
|
ImageFiles: []string{"cover.jpg"},
|
|
}}
|
|
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "t1", Name: "T1", FolderIDs: []string{"f1"}},
|
|
{ID: "t2", Name: "T2", FolderIDs: []string{"f1"}},
|
|
{ID: "t3", Name: "T3", FolderIDs: []string{"f1"}},
|
|
{ID: "t4", Name: "T4", FolderIDs: []string{"f1"}},
|
|
})
|
|
})
|
|
|
|
DescribeTable("yields a generated grid from up to 4 album tiles",
|
|
func(albumIDs []string, expectedSize int) {
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("generated"))
|
|
|
|
img, format, err := image.Decode(res.reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(format).To(Equal("png"))
|
|
Expect(img.Bounds().Dx()).To(Equal(expectedSize))
|
|
Expect(img.Bounds().Dy()).To(Equal(expectedSize))
|
|
},
|
|
// tileSize-1: the 4-tile canvas is built as [0, tileSize-1].
|
|
Entry("1 album -> single tile", []string{"t1"}, tileSize/2),
|
|
Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1),
|
|
Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1),
|
|
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
|
|
)
|
|
|
|
// The grid samples album art through the full album chain, so a playlist reaches the
|
|
// network even with the m3u fetch off.
|
|
It("calls the album image agents for its grid tiles when m3u art is disabled", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
conf.Server.CoverArtPriority = "external"
|
|
folderRepo.result = nil
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
|
ds.MockedPlaylist = plRepo
|
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
|
|
var gatedNames []string
|
|
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
gatedNames = append(gatedNames, name)
|
|
return f()
|
|
}
|
|
|
|
_, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album")
|
|
})
|
|
|
|
It("resolves the uploaded image before the generated grid", func() {
|
|
tmpDir := GinkgoT().TempDir()
|
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
|
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed())
|
|
imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed())
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("upload"))
|
|
Expect(res.sourcePath).To(Equal(imgPath))
|
|
})
|
|
|
|
It("resolves a sidecar image next to the playlist file before the grid", func() {
|
|
plDir := GinkgoT().TempDir()
|
|
Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed())
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg"))
|
|
})
|
|
|
|
It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() {
|
|
dir := GinkgoT().TempDir()
|
|
imgPath := filepath.Join(dir, "cover.png")
|
|
Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed())
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(res.sourcePath).To(Equal(imgPath))
|
|
Expect(res.refMtime).To(BeNumerically(">", 0))
|
|
})
|
|
|
|
It("never opens a local ExternalImageURL that is not an image file", func() {
|
|
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
|
|
dir := GinkgoT().TempDir()
|
|
secretPath := filepath.Join(dir, "config.ini")
|
|
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.sourcePath).ToNot(Equal(secretPath))
|
|
})
|
|
|
|
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
var gatedNames []string
|
|
gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
gatedNames = append(gatedNames, name)
|
|
return nil, "", errors.New("network down")
|
|
}
|
|
|
|
res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
|
|
})
|
|
|
|
It("records the m3u failure in the trace even when album sampling adds its own steps", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
return nil, "", errors.New("network down")
|
|
}
|
|
|
|
trace := &ChainTrace{}
|
|
res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace),
|
|
model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
|
|
steps := trace.Steps()
|
|
var m3u *TraceStep
|
|
for i := range steps {
|
|
if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError {
|
|
m3u = &steps[i]
|
|
}
|
|
}
|
|
Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback")
|
|
Expect(m3u.Detail).To(Equal("network down"),
|
|
"the trace must carry the underlying error so explain can tell a timeout from an HTTP error")
|
|
})
|
|
|
|
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
|
|
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("generated"))
|
|
Expect(res.extErr).ToNot(HaveOccurred())
|
|
})
|
|
|
|
// A local resolver holds no agents: reaching the external branch would panic, not degrade.
|
|
It("skips the external step instead of dereferencing absent agents", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
|
|
|
res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one")
|
|
})
|
|
|
|
// The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
|
|
It("resolves a playlist locally without fetching remotely or building the grid", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hits.Add(1)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"}
|
|
|
|
res, err := newLocalResolver(ds, ffm).resolve(ctx, item)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only")
|
|
Expect(hits.Load()).To(BeZero(), "a request must never reach the network")
|
|
|
|
worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(worker.reader).ToNot(BeNil())
|
|
defer worker.reader.Close()
|
|
Expect(worker.source).To(Equal("generated"), "the worker does build the grid")
|
|
Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch")
|
|
})
|
|
|
|
It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extErr).To(HaveOccurred())
|
|
})
|
|
|
|
It("yields an empty resolution when no album has art", func() {
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "empty1", Name: "Empty"},
|
|
})
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
folderRepo.result = nil
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.source).To(BeEmpty())
|
|
})
|
|
|
|
It("skips a grid tile whose declared dimensions are a decompression bomb", func() {
|
|
libRoot := GinkgoT().TempDir()
|
|
Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed())
|
|
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
|
folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}}
|
|
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.source).To(BeEmpty())
|
|
})
|
|
|
|
It("does not resolve as absent when every sampled album fails to resolve", func() {
|
|
// The album ids are absent from MockAlbumRepo, so every tile fails non-externally.
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"})
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(res).To(Equal(resolution{}))
|
|
})
|
|
})
|
|
})
|
|
|
|
// decodeTile runs before the processor's own guards, so it must enforce the caps itself.
|
|
var _ = Describe("decodeTile", func() {
|
|
It("rejects a decompression bomb before the full decode", func() {
|
|
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
|
|
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
|
})
|
|
|
|
It("rejects a tile larger than the size cap", func() {
|
|
data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1)
|
|
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Explainable", func() {
|
|
It("is true for the kinds the resolver walks", func() {
|
|
Expect(Explainable(model.KindArtistArtwork)).To(BeTrue())
|
|
Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue())
|
|
Expect(Explainable(model.KindDiscArtwork)).To(BeTrue())
|
|
Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue())
|
|
})
|
|
|
|
It("is false for the kinds resolved from a fixed internal order", func() {
|
|
Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse())
|
|
Expect(Explainable(model.KindRadioArtwork)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("MayFetchExternal", func() {
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
|
conf.Server.ArtistArtPriority = "artist.*"
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
})
|
|
|
|
It("is true for the kinds whose chain includes the external candidate", func() {
|
|
conf.Server.CoverArtPriority = "cover.*, external"
|
|
conf.Server.ArtistArtPriority = "artist.*, external"
|
|
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue())
|
|
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue())
|
|
})
|
|
|
|
It("is false for a chain with no external candidate", func() {
|
|
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse())
|
|
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse())
|
|
})
|
|
|
|
It("is true for playlists when the m3u image fetch is enabled", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
|
|
})
|
|
|
|
It("is true for playlists whose grid tiles resolve through an external album chain", func() {
|
|
conf.Server.CoverArtPriority = "cover.*, external"
|
|
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
|
|
})
|
|
|
|
It("is false for playlists with both paths off", func() {
|
|
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse())
|
|
})
|
|
|
|
It("is false for the kinds that only read local files", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
conf.Server.ArtistArtPriority = "external"
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse())
|
|
Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("ExternalLookupsPerItem", func() {
|
|
count := ImageAgentCount{Artist: 3, Album: 2}
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.CoverArtPriority = "cover.*, external"
|
|
conf.Server.ArtistArtPriority = "artist.*, external"
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
})
|
|
|
|
It("bills one call per agent, since the walk only stops early on a hit", func() {
|
|
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3)))
|
|
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2)))
|
|
})
|
|
|
|
It("bills a playlist for every album its grid samples", func() {
|
|
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
|
|
To(Equal(int64(PlaylistGridSamples) * 2))
|
|
})
|
|
|
|
It("adds the m3u image fetch on top of the grid", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
|
|
To(Equal(int64(PlaylistGridSamples)*2 + 1))
|
|
})
|
|
|
|
It("bills only the m3u fetch when the album chain stays local", func() {
|
|
conf.Server.CoverArtPriority = "cover.*"
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1)))
|
|
})
|
|
|
|
It("still bills a call when no agent is visible, which plugins never are offline", func() {
|
|
none := ImageAgentCount{}
|
|
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1)))
|
|
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1)))
|
|
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)).
|
|
To(Equal(int64(PlaylistGridSamples)))
|
|
})
|
|
|
|
It("is zero whenever the kind reaches no agent at all", func() {
|
|
conf.Server.CoverArtPriority = "cover.*"
|
|
conf.Server.ArtistArtPriority = "artist.*"
|
|
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero())
|
|
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero())
|
|
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero())
|
|
Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero())
|
|
})
|
|
})
|