mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-07 10:48:39 +01:00
Media files are never enqueued — the scanner drops their state without queueing them and the recheck kinds exclude them — so an unresolved track keeps an empty ImageHash and SongToBaseItem always fell through to AlbumPrimaryImageTag. Finamp then asks for the album image, nothing ever requests mf-, and the read-through that resolves the track never fires: the own-cover branch was unreachable for Jellyfin-only users. An eligible, unresolved, not-known-absent track now advertises its id as the Primary tag, which is what makes the client ask. The request serves the embedded art and queues the track so the worker persists a real hash. No blurhash is sent, since none exists yet and a fake would be cached against that tag forever. Serving gains the album fallback that made this safe to advertise: an eligible track whose frame will not extract now falls back the way CoverArtID does instead of answering with a placeholder. Reported by Codex on #5847.
461 lines
20 KiB
Go
461 lines
20 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("Service", 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 Service
|
|
repoRoot string
|
|
coverBytes []byte
|
|
)
|
|
|
|
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
|
|
|
|
// seedFoundStore installs a store-backed found state (bytes in the content-addressed
|
|
// store, no backing file) and returns the hash.
|
|
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())
|
|
return hash
|
|
}
|
|
|
|
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) {
|
|
r, _, err := arg.(artworkReader).Reader(ctx)
|
|
return r, err
|
|
})
|
|
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
|
|
svc = NewService(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 validator with the encode settings, distinct from
|
|
// the pixel hash, so a CoverArtQuality/WebP change invalidates client caches.
|
|
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))
|
|
|
|
// Delete the store file: a warm resize-cache entry must keep serving without
|
|
// ever touching the original (the stale-serve self-heal).
|
|
hash, _ := HashImage(bytes.NewReader(coverBytes))
|
|
Expect(store.Remove(hash, "image/jpeg", time.Now().Add(time.Hour))).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())
|
|
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())
|
|
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())
|
|
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))
|
|
})
|
|
|
|
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))
|
|
// The absent state row is left intact; only a recheck is scheduled.
|
|
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
|
|
// A resolved mf row exists (from when the setting was on) but must not be served.
|
|
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() {
|
|
// Not embedded-eligible: the fallback must mirror CoverArtID (disc first, then album).
|
|
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 wins over the album's found art, proving it routed via serveDisc.
|
|
Expect(readAll(img)).To(Equal(coverBytes))
|
|
})
|
|
|
|
// Jellyfin clients are now told to request the track image before it resolves, so an
|
|
// unextractable frame must not answer with a placeholder where the album has art.
|
|
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 yields no extractable image (it is not audio).
|
|
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() {
|
|
// A single disc can carry its own art: DiscArtPriority still applies, so the disc
|
|
// image is served even when the album has different 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 is keyed on the id and the album's mtime, not on the image bytes, so a
|
|
// warm hit needs no filesystem access. Dropping the source between the two requests is
|
|
// how that shows: re-reading would fall back to the album instead.
|
|
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())
|
|
|
|
// With the source gone and the album holding no art of its own, a re-read would
|
|
// fall through to the album and fail.
|
|
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 be replaced without the album row changing, so the key folds in the
|
|
// folder's ImagesUpdatedAt; keying on album.UpdatedAt alone would serve the old image.
|
|
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)
|
|
|
|
// The image was replaced: same album row, newer folder images timestamp.
|
|
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")
|
|
})
|
|
|
|
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))
|
|
})
|
|
|
|
// An entity that has no art and an id that names no entity are different answers:
|
|
// Subsonic reports error 70 for the latter, and Jellyfin 404s.
|
|
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()
|
|
}
|