mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* fix(playlists): limit local cover paths to images in owner's libraries A local #EXTALBUMARTURL path (absolute or file://) was only checked against the union of all libraries. The artwork resolver then opened it with no further check and served the bytes as the playlist cover, undecoded. Any user who can upload an M3U could read any file under any library root, including libraries they were not granted, through getCoverArt (GHSA-vwq6-xrw5-phpg). resolveImageURL now requires an image extension, and for uploaded playlists (no folder) the library holding the cover must pass the owner's HasLibraryAccess. Scanner and CLI imports keep the all-libraries check, since those files are admin-controlled. resolveLocalFile, used by every file-backed artwork source, now ignores paths without an image extension, which covers playlists stored before this fix that were not resolved yet. openOriginal refuses a stored file-backed row whose path is not an image, so the existing dangling path re-resolves it and the playlist falls back to the generated grid. No migration is needed. * fix(artwork): skip non-image files matched by folder cover patterns Album and disc folder sources opened any file in the folder's image list that matched a cover pattern, without checking its extension. openOriginal now refuses to serve file-backed rows whose path is not an image, so a stored row like that would be refused, re-resolved to the same file, and refused again on every view. The list comes from the scanner, which only records image files, but a database scanned where the OS mime table knows more image types than the serving process could still reach this. Both fromExternalFile variants now skip matches that are not image files, so the album falls back to its next source instead. Also correct the parser comment: a playlist without a folder can come from an API upload or from a CLI import of a file outside all libraries. * fix(artwork): check stored source type before using the resize cache The image-extension check for file-backed rows ran inside openOriginal, which the resize cache skips on a hit. Before the fix, a resized request for a playlist pointing at a non-image file cached the raw bytes, because a failed resize falls back to the original data. After the upgrade the same request still hit that entry and returned the file. serveHash now refuses a file-backed row whose path is not an image before calling serveSource, so both full-size and resized requests go through dangling and re-resolve the item. The stale cache entry is keyed by the old hash and is no longer reachable once the row changes. * test: register mime_types.yaml in test binaries Artwork resolution now skips candidates that are not image files, and model.IsImageFile answers from the process mime table. The server registers the extra image types from resources/mime_types.yaml through a conf hook, but a test binary only does that if it links conf/mime, so the artwork e2e suite fell back to the host table: .jxl resolves on macOS and Linux and does not on Windows, where the #5950 cover spec then found no source. tests.Init now imports conf/mime for its side effect, so every suite that loads the test config sees the same image types as the server. * fix(artwork): drop the image-file guard from the disc art reader The guard was added to both fromExternalFile variants, but disc artwork keeps no state row and is never queued, so it cannot hit the refuse-and-re-resolve loop the guard exists to prevent. The only case where it can fire is a real image whose extension this process's mime table does not know, and there it drops a disc cover that used to work. The album variant keeps the guard, since those resolutions are stored and re-served.
558 lines
23 KiB
Go
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(&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 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(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
|
seedEntity("al", "alni")
|
|
Expect(artRepo.PutItemArtwork(&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(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
|
seedEntity("al", "alnic")
|
|
Expect(artRepo.PutItemArtwork(&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(&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("never re-enqueues an absent state on view, however old", func() {
|
|
Expect(artRepo.PutItemArtwork(&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(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),
|
|
)
|
|
})
|