mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-13 13:48:39 +01:00
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"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/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
|
|
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
|
|
type visibilityPlaylistDS struct {
|
|
*tests.MockDataStore
|
|
private model.Playlist
|
|
tracks model.PlaylistTrackRepository
|
|
}
|
|
|
|
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
|
repo := tests.CreateMockPlaylistRepo()
|
|
repo.TracksRepo = v.tracks
|
|
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
|
repo.SetData(model.Playlists{v.private})
|
|
}
|
|
return repo
|
|
}
|
|
|
|
func adminUserRepo() *tests.MockedUserRepo {
|
|
repo := tests.CreateMockUserRepo()
|
|
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
|
return repo
|
|
}
|
|
|
|
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
|
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
|
type orderTrackingQueueRepo struct {
|
|
*tests.MockArtworkQueueRepo
|
|
callKinds []string
|
|
}
|
|
|
|
func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
|
if len(items) > 0 {
|
|
o.callKinds = append(o.callKinds, items[0].ItemKind)
|
|
}
|
|
return o.MockArtworkQueueRepo.Enqueue(items...)
|
|
}
|
|
|
|
var _ = Describe("RefreshableKinds", func() {
|
|
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
|
|
// and a drift would have `artwork explain` report state for a kind that keeps none.
|
|
It("holds exactly the kinds that keep state", func() {
|
|
for _, k := range []model.Kind{
|
|
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork,
|
|
model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork,
|
|
} {
|
|
Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String())
|
|
}
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Housekeeping", func() {
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
queueRepo *orderTrackingQueueRepo
|
|
propRepo *tests.MockedPropertyRepo
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
ctx = context.Background()
|
|
conf.Server.CoverArtPriority = "embedded, folder"
|
|
conf.Server.ArtistArtPriority = "artist.jpg"
|
|
conf.Server.Agents = "spotify"
|
|
conf.Server.EnableExternalServices = true
|
|
|
|
queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()}
|
|
propRepo = &tests.MockedPropertyRepo{}
|
|
ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo}
|
|
})
|
|
|
|
seedEntities := func() {
|
|
artistRepo := tests.CreateMockArtistRepo()
|
|
artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}})
|
|
ds.MockedArtist = artistRepo
|
|
|
|
albumRepo := tests.CreateMockAlbumRepo()
|
|
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
|
ds.MockedAlbum = albumRepo
|
|
|
|
playlistRepo := tests.CreateMockPlaylistRepo()
|
|
playlistRepo.SetData(model.Playlists{{ID: "pl1"}})
|
|
ds.MockedPlaylist = playlistRepo
|
|
|
|
radioRepo := tests.CreateMockedRadioRepo()
|
|
radioRepo.All = model.Radios{{ID: "ra1"}}
|
|
ds.MockedRadio = radioRepo
|
|
}
|
|
|
|
Describe("Fingerprint", func() {
|
|
It("changes when a fingerprint-affecting config value changes", func() {
|
|
f1 := ConfigFingerprint()
|
|
conf.Server.CoverArtPriority = "folder, embedded"
|
|
f2 := ConfigFingerprint()
|
|
Expect(f1).NotTo(Equal(f2))
|
|
})
|
|
|
|
It("changes when ArtistImageFolder changes", func() {
|
|
conf.Server.ArtistImageFolder = "/before"
|
|
f1 := ConfigFingerprint()
|
|
conf.Server.ArtistImageFolder = "/after"
|
|
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
|
})
|
|
|
|
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
f1 := ConfigFingerprint()
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
|
})
|
|
|
|
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
|
|
It("hashes a given config to a stable value", func() {
|
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
|
conf.Server.ArtistArtPriority = "artist.*, external"
|
|
conf.Server.ArtistImageFolder = ""
|
|
conf.Server.Agents = "lastfm,spotify"
|
|
conf.Server.EnableExternalServices = true
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
|
|
Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d"))
|
|
})
|
|
|
|
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
|
|
conf.Server.Agents = "lastfm,spotify"
|
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
|
|
|
Expect(FingerprintInputs()).To(ContainElements(
|
|
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},
|
|
FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"},
|
|
))
|
|
})
|
|
|
|
It("does not change when the server version changes", func() {
|
|
original := consts.Version
|
|
DeferCleanup(func() { consts.Version = original })
|
|
f1 := ConfigFingerprint()
|
|
consts.Version = original + "-next"
|
|
Expect(ConfigFingerprint()).To(Equal(f1),
|
|
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
|
|
})
|
|
})
|
|
|
|
Describe("Backfill", func() {
|
|
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
|
|
seedEntities()
|
|
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
|
|
|
|
did, err := backfill(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(did).To(BeFalse())
|
|
|
|
count, err := queueRepo.Count()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(BeZero())
|
|
})
|
|
|
|
It("runs the backfill when no fingerprint was ever stored", func() {
|
|
seedEntities()
|
|
|
|
did, err := backfill(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(did).To(BeTrue())
|
|
|
|
count, err := queueRepo.Count()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio
|
|
|
|
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(stored).To(Equal(ConfigFingerprint()))
|
|
})
|
|
|
|
It("enqueues a private playlist by resolving it under an admin context", func() {
|
|
ds.MockedUser = adminUserRepo()
|
|
vds := &visibilityPlaylistDS{
|
|
MockDataStore: ds,
|
|
private: model.Playlist{ID: "plPrivate", OwnerID: "admin"},
|
|
tracks: &tests.MockPlaylistTrackRepo{},
|
|
}
|
|
|
|
did, err := backfill(ctx, vds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(did).To(BeTrue())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
|
})
|
|
|
|
It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() {
|
|
seedEntities()
|
|
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
|
|
|
did, err := backfill(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(did).To(BeTrue())
|
|
|
|
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
|
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
|
|
Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call")
|
|
if firstOther >= 0 {
|
|
Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"),
|
|
"no artist Enqueue may follow another kind")
|
|
}
|
|
|
|
for _, it := range queueRepo.Data {
|
|
Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill))
|
|
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("EnqueueStaleAbsentAll", func() {
|
|
var artRepo *tests.MockArtworkRepo
|
|
|
|
BeforeEach(func() {
|
|
artRepo = tests.CreateMockArtworkRepo()
|
|
ds.MockedArtwork = artRepo
|
|
queueRepo.ItemArtworkSource = artRepo
|
|
})
|
|
|
|
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
|
old := time.Now().Add(-48 * time.Hour)
|
|
recent := time.Now().Add(-time.Hour)
|
|
|
|
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
|
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
|
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
|
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
|
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
|
|
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
|
|
|
|
err := enqueueStaleAbsentAll(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(queueRepo.Data).To(HaveLen(4))
|
|
for _, it := range queueRepo.Data {
|
|
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
|
}
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("EnqueueMissingAll", func() {
|
|
var artRepo *tests.MockArtworkRepo
|
|
|
|
BeforeEach(func() {
|
|
artRepo = tests.CreateMockArtworkRepo()
|
|
ds.MockedArtwork = artRepo
|
|
queueRepo.ItemArtworkSource = artRepo
|
|
queueRepo.ExistingIDs = map[string]map[string]bool{
|
|
"al": {"al1": true, "al2": true},
|
|
"ar": {"ar1": true},
|
|
"pl": {"pl1": true},
|
|
"ra": {"ra1": true},
|
|
}
|
|
})
|
|
|
|
It("enqueues only entities that have no item_artwork row, across all kinds", func() {
|
|
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()}
|
|
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}
|
|
|
|
err := enqueueMissingAll(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
for _, it := range queueRepo.Data {
|
|
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
|
}
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil())
|
|
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil())
|
|
})
|
|
})
|
|
})
|