mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-27 13:57:30 +01:00
The playlist ExternalImageURL step used sources.go's fromURL, which maps any non-200 to a generic error, so a stale URL returning 404/410 was classified transient: infinite backoff plus it counted toward the circuit breaker, blocking valid external work. Add a local fetch in resolve.go that maps 404/410 to model.ErrNotFound (definitive) while keeping other non-200s transient. sources.go is left untouched.
525 lines
20 KiB
Go
525 lines
20 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakeExternalProvider is a minimal external.Provider stub for resolve_test.go;
|
|
// only AlbumImage/ArtistImage are exercised by the resolvers.
|
|
type fakeExternalProvider struct {
|
|
external.Provider
|
|
albumImage func(ctx context.Context, id string) (*url.URL, error)
|
|
artistImage func(ctx context.Context, id string) (*url.URL, error)
|
|
}
|
|
|
|
func (f *fakeExternalProvider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
|
if f.albumImage != nil {
|
|
return f.albumImage(ctx, id)
|
|
}
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeExternalProvider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
|
if f.artistImage != nil {
|
|
return f.artistImage(ctx, id)
|
|
}
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeExternalProvider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) {
|
|
return f.ArtistImage(ctx, id)
|
|
}
|
|
|
|
var _ = Describe("resolveItem", func() {
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
folderRepo *fakeFolderRepo
|
|
libRepo *tests.MockLibraryRepo
|
|
ffm *tests.MockFFmpeg
|
|
prov *fakeExternalProvider
|
|
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("")
|
|
prov = &fakeExternalProvider{}
|
|
ds = &tests.MockDataStore{
|
|
MockedFolder: folderRepo,
|
|
MockedLibrary: libRepo,
|
|
}
|
|
})
|
|
|
|
Describe("kind dispatch", func() {
|
|
It("returns an error for kinds the worker never enqueues", func() {
|
|
_, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil)
|
|
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.extError).To(BeFalse())
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil)
|
|
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 extError 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"},
|
|
})
|
|
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
|
return nil, errors.New("agent timed out")
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeTrue())
|
|
})
|
|
|
|
It("does not set extError when the external source reports not-found", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al4", Name: "Album"},
|
|
})
|
|
// prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeFalse())
|
|
})
|
|
|
|
It("carries extError 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"}},
|
|
})
|
|
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
|
return nil, errors.New("agent timed out")
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(res.extError).To(BeTrue())
|
|
})
|
|
|
|
It("does not carry extError 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"}},
|
|
})
|
|
// prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("folder"))
|
|
Expect(res.extError).To(BeFalse())
|
|
})
|
|
|
|
It("routes the external step through a custom extGate", func() {
|
|
conf.Server.CoverArtPriority = "external"
|
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
{ID: "al5", Name: "Album"},
|
|
})
|
|
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
var extGateCalls int
|
|
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
extGateCalls++
|
|
return f()
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, extGate)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.extError).To(BeTrue())
|
|
Expect(extGateCalls).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil)
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil)
|
|
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 extError 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
|
|
prov.artistImage = func(context.Context, string) (*url.URL, error) {
|
|
return nil, errors.New("agent timed out")
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeTrue())
|
|
})
|
|
|
|
It("does not set extError 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
|
|
// prov.artistImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeFalse())
|
|
})
|
|
|
|
It("routes the external step through a custom extGate", func() {
|
|
conf.Server.ArtistArtPriority = "external"
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
|
|
ds.MockedArtist = artistRepo
|
|
prov.artistImage = func(context.Context, string) (*url.URL, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
var extGateCalls int
|
|
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
extGateCalls++
|
|
return f()
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, extGate)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.extError).To(BeTrue())
|
|
Expect(extGateCalls).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil)
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil)
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil)
|
|
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], matching
|
|
// reader_playlist.go's createTiledImage exactly.
|
|
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),
|
|
)
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil)
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil)
|
|
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("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 extGateCalls int
|
|
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
|
extGateCalls++
|
|
return nil, "", errors.New("network down")
|
|
}
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, extGate)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeTrue())
|
|
Expect(extGateCalls).To(Equal(1))
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeFalse())
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).ToNot(BeNil())
|
|
defer res.reader.Close()
|
|
Expect(res.source).To(Equal("generated"))
|
|
Expect(res.extError).To(BeFalse())
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(res.reader).To(BeNil())
|
|
Expect(res.extError).To(BeTrue())
|
|
})
|
|
|
|
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 := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil)
|
|
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() {
|
|
// "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum
|
|
// returns a genuine (non-external) error for every sampled tile.
|
|
plRepo := tests.CreateMockPlaylistRepo()
|
|
plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}})
|
|
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}}
|
|
ds.MockedPlaylist = plRepo
|
|
|
|
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(res).To(Equal(resolution{}))
|
|
})
|
|
})
|
|
})
|