navidrome/core/artwork/serving_test.go
Deluan f4e14e9c1a fix(artwork): fall back to disc art, not the album, for multi-disc tracks
serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID,
skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy
reader) use. On multi-disc albums with per-disc images that served the album cover
instead of the configured disc artwork. Delegate through DiscCoverArtID.
2026-07-23 14:08:08 -04:00

337 lines
13 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())
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("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("al", "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("returns ErrUnavailable for an absent state without enqueuing", func() {
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "al4"})).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("al", "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("al", "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("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("mf", "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"}}})
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))
})
})
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))
})
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))
})
})
})
func fileMtime(path string) int64 {
GinkgoHelper()
info, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
return info.ModTime().Unix()
}