mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(cache): write the completion marker before closing the cache writer Readers of an in-progress cache write see EOF the moment the writer closes, but the .complete marker was created after the close, on the background goroutine — so a fully-read stream did not mean the cache was done touching disk. The new artwork precache spec ends right at EOF, and its GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI job with 'unlinkat ...: The directory is not empty' (the race also reproduces on macOS, 2 of 3 runs, with the tightened test). Writing the marker after a clean copy but before Close makes reader-EOF imply every on-disk write for the entry is finished. A failed writer Close still invalidates the entry, which removes both the marker and the data file. The existing marker test now asserts the marker exists immediately at EOF instead of Eventually. * fix(artwork): never dispatch queue items after the drain context is cancelled The 10x Windows stress run for the previous commit surfaced a second flake in the same package: 'leaves undispatched items queued when cancelled mid-batch' lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the context is already cancelled, both cases of the blocking select are ready and Go picks one at random — so a cancelled drain could still dispatch items. A non-blocking Done check before the select gives cancellation priority. The race was invisible on Linux/macOS only by accident: the spec seeded the album repo with a single album (each SetData overwrote the last), so only the final row (alc7) resolved to absent and got deleted when dispatched; the others fell on the retry path and survived. Nanosecond enqueue timestamps made alc0 always first out of the mock dequeue, masking the race, while Windows' coarse clock ties the timestamps and randomizes the order. The spec now seeds all eight albums, which made the race reproduce locally on the first try (row alc0) and now guards the fix on every platform. * test: give cache-init waits a 10s timeout for loaded CI runners A 10x parallel Windows stress run timed out one artwork spec in BeforeEach: the FileCache init goroutine (mkdir + reload walk) took over Gomega's default 1s Eventually timeout under shared-runner disk contention. Bump the three identical init waits (two artwork suites and the utils/cache helper) to 10s. * test(scanner): widen watcher debounce margins for loaded CI runners The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently windows while the debounce wait was only 50ms — a 2.5x margin that a loaded Windows runner blows through by delaying the timer-reset notification, firing the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the test debounce wait to 200ms (10x the observation windows) and the scan-fired Eventually timeouts to 2s to match. * refactor(artwork): collapse drain cancellation into a single exit path Replace the non-blocking ctx pre-check plus duplicated select exit with one select and a ctx.Err() check after it. Besides removing the duplication, this closes the residual race: a cancellation landing between the two selects could still let the blocking select randomly pick the free semaphore slot and dispatch the item. Now a dispatch is only possible when the context was live after slot acquisition. * style: trim flaky-test fix comments to single lines Compress each two-line comment added by this PR to the one line that carries the invariant; drop the narration around it.
524 lines
21 KiB
Go
524 lines
21 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(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
|
Expect(artRepo.PutItemArtwork(&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(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
|
case "mf":
|
|
Expect(mfRepo.Put(&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(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
|
seedEntity("al", "al2")
|
|
Expect(artRepo.PutItemArtwork(&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 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(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
|
seedEntity("al", "al3")
|
|
Expect(artRepo.PutItemArtwork(&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(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(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
|
seedEntity("al", "al3b")
|
|
Expect(artRepo.PutItemArtwork(&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("does not re-enqueue a recently-attempted absent state", func() {
|
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
|
|
})).To(Succeed())
|
|
|
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
|
|
Expect(err).To(MatchError(ErrUnavailable))
|
|
Expect(queueRepo.Data).To(BeEmpty())
|
|
})
|
|
|
|
It("promotes a stale absent state at Bump priority on view", func() {
|
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge),
|
|
})).To(Succeed())
|
|
|
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
|
|
Expect(err).To(MatchError(ErrUnavailable))
|
|
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
|
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ia.Hash).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(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(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(&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(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(&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(&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),
|
|
)
|
|
})
|