mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-23 01:37:19 +02:00
Two gaps in when the pipeline re-resolves artwork: The recheck job only requeued absent-state rows (hash=''), so an entity that was never processed — added between scans, or on a server with the scanner disabled — had no periodic safety net and stayed without artwork indefinitely. Add EnqueueMissing(kind): a SQL set-difference enqueueing entities with no item_artwork row at Recheck priority (ON CONFLICT DO NOTHING, so it never disturbs a queued row). Run it once at startup and hourly alongside the stale-absent recheck. Rename staleAbsentKinds -> recheckKinds accordingly. Conversely, the config fingerprint included consts.Version, which embeds the git SHA and so changed on every build, re-enqueueing every entity in the library (~34k here) and re-querying external agents at the configured RPS for anything without local art. Replace it with an explicit artworkEpoch constant, bumped deliberately when resolution semantics change. The cases that motivated the version input — absent art becoming available — are already covered by the stale-absent and missing-row rechecks; only a corrected wrong-pick needs the epoch. A test guards against reintroducing the version.
270 lines
9.5 KiB
Go
270 lines
9.5 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"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("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 := Fingerprint()
|
|
conf.Server.CoverArtPriority = "folder, embedded"
|
|
f2 := Fingerprint()
|
|
Expect(f1).NotTo(Equal(f2))
|
|
})
|
|
|
|
It("changes when ArtistImageFolder changes", func() {
|
|
conf.Server.ArtistImageFolder = "/before"
|
|
f1 := Fingerprint()
|
|
conf.Server.ArtistImageFolder = "/after"
|
|
Expect(Fingerprint()).NotTo(Equal(f1))
|
|
})
|
|
|
|
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
|
conf.Server.EnableM3UExternalAlbumArt = false
|
|
f1 := Fingerprint()
|
|
conf.Server.EnableM3UExternalAlbumArt = true
|
|
Expect(Fingerprint()).NotTo(Equal(f1))
|
|
})
|
|
|
|
It("does not change when the server version changes", func() {
|
|
original := consts.Version
|
|
DeferCleanup(func() { consts.Version = original })
|
|
f1 := Fingerprint()
|
|
consts.Version = original + "-next"
|
|
Expect(Fingerprint()).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(FingerprintPropertyKey, Fingerprint())).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(FingerprintPropertyKey)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(stored).To(Equal(Fingerprint()))
|
|
})
|
|
|
|
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(FingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
|
|
|
did, err := Backfill(ctx, ds)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(did).To(BeTrue())
|
|
|
|
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
|
artistCallIdx := -1
|
|
for i, k := range queueRepo.callKinds {
|
|
if k == "ar" {
|
|
artistCallIdx = i
|
|
break
|
|
}
|
|
}
|
|
Expect(artistCallIdx).To(Equal(0), "artists must be the first Enqueue call")
|
|
for i, k := range queueRepo.callKinds {
|
|
if k != "ar" {
|
|
Expect(i).To(BeNumerically(">", artistCallIdx))
|
|
}
|
|
}
|
|
|
|
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}
|
|
// Not stale: too recent.
|
|
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
|
|
// Not absent: has a resolved hash.
|
|
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() {
|
|
// al1 is already resolved and ar1 already absent: both must be skipped.
|
|
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())
|
|
})
|
|
})
|
|
})
|