navidrome/core/artwork/artwork_test.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

558 lines
23 KiB
Go

package artwork
import (
"bytes"
"context"
"image"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
albumRepo *tests.MockAlbumRepo
mfRepo *tests.MockMediaFileRepo
folderRepo *fakeFolderRepo
libRepo *tests.MockLibraryRepo
ffm *tests.MockFFmpeg
store *ImageStore
imgCache cache.FileCache
svc Artwork
repoRoot string
coverBytes []byte
seedEntity func(kind, id string)
)
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
seedFoundStore := func(kind, id string, imgBytes []byte) string {
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
seedEntity(kind, id)
return hash
}
// Without its owning entity, a state row is not served at all.
seedEntity = func(kind, id string) {
GinkgoHelper()
switch kind {
case "al":
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
case "mf":
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
}
}
readAll := func(img *Image) []byte {
GinkgoHelper()
defer img.Close()
data, err := io.ReadAll(img)
Expect(err).ToNot(HaveOccurred())
return data
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
conf.Server.EnableWebPEncoding = false
conf.Server.CoverArtQuality = 75
conf.Server.CoverArtPriority = "cover.*"
conf.Server.DiscArtPriority = "cover.*"
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
albumRepo = tests.CreateMockAlbumRepo()
mfRepo = tests.CreateMockMediaFileRepo()
folderRepo = &fakeFolderRepo{}
libRepo = &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedArtwork: artRepo,
MockedArtworkQueue: queueRepo,
MockedAlbum: albumRepo,
MockedMediaFile: mfRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
store = NewImageStore(GinkgoT().TempDir())
imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
return arg.(artworkReader).Reader(ctx)
})
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
svc = NewArtwork(ds, imgCache, store, ffm)
})
Describe("found state", func() {
It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() {
seedFoundStore("al", "al1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
Expect(err).ToNot(HaveOccurred())
// A resized response versions its ETag with the encode settings, not the pixel hash.
Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false)))
Expect(img.ETag).ToNot(Equal(img.Hash))
resized := readAll(img)
cfg, _, err := image.DecodeConfig(bytes.NewReader(resized))
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(100))
// Deleting the store file proves the warm entry serves without touching the original.
hash, _ := hashImage(bytes.NewReader(coverBytes))
Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed())
Eventually(func(g Gomega) {
img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(readAll(img2)).To(Equal(resized))
}).Should(Succeed())
})
It("treats a negative size as a full-size request, not a giant resize", func() {
seedFoundStore("al", "alneg", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)")
})
It("streams a file-backed found image at full size", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
mtime := fileMtime(imgPath)
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al2")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
})).To(Succeed())
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("treats a file-backed row pointing at a non-image file as dangling", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
secret := []byte("password=secret")
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
// Older versions cached the raw bytes when the resize failed.
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
Expect(err).ToNot(HaveOccurred())
Expect(io.ReadAll(stream)).To(Equal(secret))
Expect(stream.Close()).To(Succeed())
Eventually(func(g Gomega) {
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(s.Cached).To(BeTrue())
_ = s.Close()
}).Should(Succeed())
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
})
It("enforces the mtime rule on the sized (loader) path too", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "al3b")
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
})
// State rows outlive a deleted entity until the next prune.
It("refuses to serve a found row whose entity is gone", func() {
hash := seedFoundStore("al", "alzz", coverBytes)
Expect(hash).ToNot(BeEmpty())
albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
})
It("never re-enqueues an absent state on view, however old", func() {
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data).To(BeEmpty())
})
})
Describe("provisional read-through", func() {
It("serves local folder art, enqueues a Bump, and writes no state row", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}})
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("media file", func() {
It("serves a track's own found art", func() {
seedFoundStore("mf", "mf1", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
seedFoundStore("al", "albz", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art")
})
It("delegates to the album when the track's state is absent", func() {
seedFoundStore("al", "albm", coverBytes)
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")]
Expect(mfEnq).To(BeFalse())
})
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
seedFoundStore("al", "albn", coverBytes)
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")]
Expect(mfEnq).To(BeFalse())
})
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
conf.Server.EnableMediaFileCoverArt = true
mfRepo.SetData(model.MediaFiles{{
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(len(readAll(img))).To(BeNumerically(">", 0))
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("delegates a multi-disc track to its disc art, not straight to the album", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}})
seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs
mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false)
Expect(err).ToNot(HaveOccurred())
// The disc-folder image, not the album's own art: proof it routed through serveDisc.
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to the album when an eligible track's embedded art will not extract", func() {
conf.Server.EnableMediaFileCoverArt = true
// HasCoverArt is set, but the file is not audio, so nothing extracts.
mfRepo.SetData(model.MediaFiles{{
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/front.png",
}})
albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}})
seedFoundStore("al", "albad", coverBytes)
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover")
})
It("routes a single-disc track through disc resolution too", func() {
// DiscArtPriority applies to single-disc albums too, over the album's own found art.
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}})
seedFoundStore("al", "alsd", []byte("album-art-distinct"))
mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}})
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
})
Describe("disc", func() {
It("serves a local disc-folder image", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}})
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
// The resize cache keys on id + album mtime, not on the bytes, so dropping the source
// between the two requests is what shows a warm hit never touches the filesystem.
It("serves a sized disc image from cache without re-reading the source", func() {
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil)
first, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
warmed := readAll(first)
Expect(warmed).ToNot(BeEmpty())
folderRepo.result = nil
second, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source")
})
// A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt.
It("invalidates the cached image when the folder's images change", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
ImagesUpdatedAt: time.Now().Add(-time.Hour),
}}
albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil)
first, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
firstKey := first.ETag
readAll(first)
folderRepo.result[0].ImagesUpdatedAt = time.Now()
second, err := svc.Get(ctx, discID, 64, false)
Expect(err).ToNot(HaveOccurred())
readAll(second)
Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry")
})
// Disc art has no content hash, so without an explicit validator every ETag would be empty.
It("gives a full-size disc image a validator that tracks the source", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
ImagesUpdatedAt: time.Now().Add(-time.Hour),
}}
albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}})
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil)
first, err := svc.Get(ctx, discID, 0, false)
Expect(err).ToNot(HaveOccurred())
readAll(first)
Expect(first.ETag).ToNot(BeEmpty())
folderRepo.result[0].ImagesUpdatedAt = time.Now()
second, err := svc.Get(ctx, discID, 0, false)
Expect(err).ToNot(HaveOccurred())
readAll(second)
Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged")
})
It("falls back to album art when no disc image matches", func() {
folderRepo.result = nil
albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}})
seedFoundStore("al", "aldc2", coverBytes)
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(readAll(img)).To(Equal(coverBytes))
})
})
Describe("GetOrPlaceholder", func() {
It("accepts a raw entity id and serves its cover art", func() {
albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}})
seedFoundStore("al", "rawal", coverBytes)
img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(coverBytes))
})
It("falls back to the album placeholder ignoring size and square", func() {
img, err := svc.GetOrPlaceholder(ctx, "", 300, true)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
Expect(img.Hash).To(BeEmpty())
Expect(img.LastUpdated).To(BeZero())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
It("falls back to the artist placeholder for an absent artist", func() {
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeTrue())
ph, err := resources.FS().Open(consts.PlaceholderArtistArt)
Expect(err).ToNot(HaveOccurred())
phBytes, _ := io.ReadAll(ph)
Expect(readAll(img)).To(Equal(phBytes))
})
// "No art" and "no such entity" are different answers: clients 404 only on the latter.
It("reports not-found rather than a placeholder for an id with no entity", func() {
_, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
})
func fileMtime(path string) int64 {
GinkgoHelper()
info, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
return info.ModTime().UnixNano()
}
var _ = Describe("EntityExists", func() {
var ctx context.Context
var ds *tests.MockDataStore
BeforeEach(func() {
ctx = context.Background()
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al1"}})
artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar1"}})
radioRepo := tests.CreateMockedRadioRepo()
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
})
DescribeTable("reports whether the owning entity is still there",
func(id string, expected bool) {
Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected))
},
Entry("existing album", "al-al1", true),
Entry("deleted album", "al-gone", false),
Entry("existing artist", "ar-ar1", true),
Entry("deleted artist", "ar-gone", false),
Entry("existing radio", "ra-ra1", true),
Entry("deleted radio", "ra-gone", false),
// Disc art has no entity of its own; it stands or falls with its album.
Entry("disc of an existing album", "dc-al1:1", true),
Entry("disc of a deleted album", "dc-gone:1", false),
Entry("malformed disc id", "dc-nodiscnum", false),
)
})