mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(artwork): add worker-side artwork resolvers
This commit is contained in:
parent
2efa697e52
commit
967de74bf7
332
core/artwork/resolve.go
Normal file
332
core/artwork/resolve.go
Normal file
@ -0,0 +1,332 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// resolution is one attempted acquisition outcome for an entity.
|
||||
type resolution struct {
|
||||
reader io.ReadCloser // nil when no source yielded an image
|
||||
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
|
||||
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
|
||||
refMtime int64 // mtime of sourcePath at resolution time; 0 when no sourcePath
|
||||
extError bool // an external source errored/timed out (forces failed, never absent)
|
||||
}
|
||||
|
||||
// extGateFunc is an alias for the external-step wrapper Task 4 injects (rate
|
||||
// limiter + circuit breaker); resolveItem defaults to a plain passthrough.
|
||||
type extGateFunc = func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
|
||||
|
||||
func passthroughExtGate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return f()
|
||||
}
|
||||
|
||||
// resolveItem walks the kind's priority chain and returns the first hit.
|
||||
func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)) (resolution, error) {
|
||||
if extGate == nil {
|
||||
extGate = passthroughExtGate
|
||||
}
|
||||
switch item.ItemKind {
|
||||
case "al":
|
||||
return resolveAlbum(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "ar":
|
||||
return resolveArtist(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "pl":
|
||||
return resolvePlaylist(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "ra":
|
||||
return resolveRadio(ctx, ds, item.ItemID)
|
||||
default:
|
||||
return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAlbum ports the folder/embedded/external selection from
|
||||
// reader_album.go, walking conf.Server.CoverArtPriority.
|
||||
func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, albumID string, extGate extGateFunc) (resolution, error) {
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok {
|
||||
return res, nil
|
||||
}
|
||||
case pattern == "external":
|
||||
r, path, err := extGate(fromAlbumExternalSource(ctx, *al, prov))
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "external", sourcePath: path}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
extErr = true
|
||||
}
|
||||
case len(imgFiles) > 0:
|
||||
if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolveArtist ports the upload/folder/external selection from
|
||||
// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority.
|
||||
func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, artistID string, extGate extGateFunc) (resolution, error) {
|
||||
ar, err := ds.Artist(ctx).Get(artistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if res, ok := resolveLocalFile(ar.UploadedImagePath(), "upload"); ok {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Only consider albums where the artist is the sole album artist, same as reader_artist.go.
|
||||
als, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
albumPaths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, als...)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
artistFolder, _, err := loadArtistFolder(ctx, ds, als, albumPaths)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
var lib libraryView
|
||||
if len(als) > 0 {
|
||||
lib, err = loadLibraryView(ctx, ds, als[0].LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
r, path, err := extGate(fromArtistExternalSource(ctx, *ar, prov))
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "external", sourcePath: path}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
extErr = true
|
||||
}
|
||||
case pattern == "image-folder":
|
||||
if res, ok := resolveArtistImageFolder(ar); ok {
|
||||
return res, nil
|
||||
}
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
if lib.FS == nil {
|
||||
continue
|
||||
}
|
||||
if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok {
|
||||
return res, nil
|
||||
}
|
||||
default:
|
||||
if lib.FS == nil || artistFolder == "" {
|
||||
continue
|
||||
}
|
||||
if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolvePlaylist ports the 2x2 generated grid from reader_playlist.go,
|
||||
// sourcing tiles through resolveAlbum instead of the old cached reader.
|
||||
func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, playlistID string, extGate extGateFunc) (resolution, error) {
|
||||
pl, err := ds.Playlist(ctx).Get(playlistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var tiles []image.Image
|
||||
var extErr bool
|
||||
for _, albumID := range albumIDs {
|
||||
res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if res.extError {
|
||||
extErr = true
|
||||
}
|
||||
if res.reader == nil {
|
||||
continue
|
||||
}
|
||||
tile, decErr := decodeTile(res.reader)
|
||||
res.reader.Close()
|
||||
if decErr == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles.
|
||||
switch len(tiles) {
|
||||
case 0:
|
||||
return resolution{extError: extErr}, nil
|
||||
case 2:
|
||||
tiles = append(tiles, tiles[1], tiles[0])
|
||||
case 3:
|
||||
tiles = append(tiles, tiles[0])
|
||||
}
|
||||
r, err := assembleTiles(tiles)
|
||||
if err != nil {
|
||||
return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error
|
||||
}
|
||||
return resolution{reader: r, source: "generated"}, nil
|
||||
}
|
||||
|
||||
// resolveRadio ports reader_radio.go: only an uploaded image, no fallback.
|
||||
func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (resolution, error) {
|
||||
r, err := ds.Radio(ctx).Get(radioID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
res, _ := resolveLocalFile(r.UploadedImagePath(), "upload")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) {
|
||||
if embedRel == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
abs := lib.Abs(embedRel)
|
||||
mtime := mtimeViaFS(lib.FS, embedRel)
|
||||
if r, _, _ := fromTag(ctx, lib.FS, embedRel)(); r != nil {
|
||||
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true
|
||||
}
|
||||
if r, _, _ := fromFFmpegTag(ctx, ffm, abs)(); r != nil {
|
||||
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtime}, true
|
||||
}
|
||||
return resolution{}, false
|
||||
}
|
||||
|
||||
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
||||
r, path, _ := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
|
||||
if r == nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
|
||||
}
|
||||
|
||||
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder")
|
||||
}
|
||||
|
||||
func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) {
|
||||
r, path, _ := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)()
|
||||
if r == nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
// resolveLocalFile opens an absolute path directly (uploads, image-folder). A
|
||||
// missing or unreadable path is "no source", not an error.
|
||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
if path == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
func mtimeOf(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().Unix()
|
||||
}
|
||||
|
||||
// mtimeViaFS stats through the library FS instead of a joined absolute path,
|
||||
// since library roots in tests may not be real OS paths (e.g. testfile://).
|
||||
func mtimeViaFS(fsys fs.FS, name string) int64 {
|
||||
if fsys == nil || name == "" {
|
||||
return 0
|
||||
}
|
||||
info, err := fs.Stat(fsys, name)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().Unix()
|
||||
}
|
||||
|
||||
// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/
|
||||
// createTiledImage, reusing the same rect/fillCenter cropping helpers.
|
||||
func decodeTile(r io.ReadCloser) (image.Image, error) {
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func assembleTiles(tiles []image.Image) (io.ReadCloser, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
var err error
|
||||
if len(tiles) == 4 {
|
||||
rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||
err = png.Encode(buf, rgba)
|
||||
} else {
|
||||
err = png.Encode(buf, tiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
305
core/artwork/resolve_test.go
Normal file
305
core/artwork/resolve_test.go
Normal file
@ -0,0 +1,305 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"io"
|
||||
"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
|
||||
}
|
||||
|
||||
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("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"))
|
||||
})
|
||||
})
|
||||
|
||||
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("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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -14,6 +14,7 @@ type MockPlaylistTrackRepo struct {
|
||||
Reordered bool
|
||||
AddCount int
|
||||
Err error
|
||||
AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options
|
||||
}
|
||||
|
||||
func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) {
|
||||
@ -66,6 +67,13 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
return m.AlbumIDs, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user