feat(artwork): expose a tracing resolver for the CLI

This commit is contained in:
Deluan 2026-08-14 12:59:29 -04:00
commit e3c78a9cb1
4 changed files with 185 additions and 0 deletions

View file

@ -223,6 +223,18 @@ func CreateArtworkWorker() *artwork.Worker {
return worker
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.Resolver {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
fFmpeg := ffmpeg.New()
resolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
return resolver
}
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)

View file

@ -144,6 +144,13 @@ func CreateArtworkWorker() *artwork.Worker {
))
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.Resolver {
panic(wire.Build(
allProviders,
artwork.NewTracingResolver,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,

View file

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -386,6 +387,47 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
return model.ArtworkID{}, model.ErrNotFound
}
type ChainTrace = chainTrace
type TraceStep = traceStep
// Resolver is the CLI's read-only view of resolution: it walks the priority chain, records
// the walk and reports the winning source, without ever writing artwork state.
type Resolver struct {
inner *resolver
trace *ChainTrace
}
// NewTracingResolver builds a Resolver that records its priority-chain walk. With live
// false the external tier is reported but never called.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *Resolver {
gate := offlineGate(t)
if live {
gate = tracingGate(t, passthroughGate)
}
return &Resolver{inner: newResolver(ds, ag, ffm, gate), trace: t}
}
func (r *Resolver) ResolveArtist(ctx context.Context, id string) (string, error) {
return r.explain(ctx, r.inner.resolveArtist, id)
}
func (r *Resolver) ResolveAlbum(ctx context.Context, id string) (string, error) {
return r.explain(ctx, r.inner.resolveAlbum, id)
}
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
// would close the reader either.
func (r *Resolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
res, err := resolve(withTrace(ctx, r.trace), id)
if err != nil {
return "", err
}
if res.reader != nil {
_ = res.reader.Close()
}
return res.source, nil
}
func unixMtime(mtime int64) time.Time {
if mtime <= 0 {
return time.Time{}

View file

@ -374,3 +374,127 @@ var _ = Describe("resolveArtist tracing", func() {
"an upload that exists and will not open must not look like an absent upload")
})
})
var _ = Describe("NewTracingResolver", func() {
var (
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
artistRepo *tests.MockArtistRepo
artworkRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.CoverArtPriority = "external, embedded"
conf.Server.ArtistArtPriority = "external"
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
artistRepo = tests.CreateMockArtistRepo()
artworkRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedArtist: artistRepo,
MockedFolder: &fakeFolderRepo{},
MockedLibrary: libRepo,
MockedArtwork: artworkRepo,
MockedArtworkQueue: queueRepo,
}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
})
It("uses the offline gate when live is false", func() {
r := NewTracingResolver(nil, nil, nil, t, false)
Expect(r).ToNot(BeNil())
})
Context("offline", func() {
var fake *fakeImageAgent
BeforeEach(func() {
fake = &fakeImageAgent{name: "offline-probe"}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
})
It("reports the external tier without asking any agent", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveAlbum(context.Background(), "al1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider")
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: outcomeWouldTry}))
})
It("records the local chain steps too", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveAlbum(context.Background(), "al1")
Expect(err).ToNot(HaveOccurred())
last := t.Steps()[len(t.Steps())-1]
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate")
Expect(last.Outcome).To(Equal(outcomeHit))
})
It("never persists artwork state", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveAlbum(context.Background(), "al1")
Expect(err).ToNot(HaveOccurred())
Expect(artworkRepo.ItemData).To(BeEmpty(),
"an offline resolution carries extError, which must never be recorded as a real provider failure")
Expect(queueRepo.Data).To(BeEmpty())
})
It("resolves an artist without persisting anything", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveArtist(context.Background(), "ar1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(BeEmpty())
Expect(fake.artistCalls).To(BeZero())
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: outcomeWouldTry}))
Expect(artworkRepo.ItemData).To(BeEmpty())
Expect(queueRepo.Data).To(BeEmpty())
})
It("closes the reader it does not hand back", func() {
conf.Server.CoverArtPriority = "embedded"
ffm = tests.NewMockFFmpeg("fake image bytes")
albumRepo.SetData(model.Albums{{
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
}})
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveAlbum(context.Background(), "al2")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation")
})
It("propagates a lookup error", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).ResolveAlbum(context.Background(), "nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
It("asks the agents when live is true", func() {
fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).ResolveAlbum(context.Background(), "al1")
Expect(err).ToNot(HaveOccurred())
Expect(fake.albumCalls).To(Equal(1))
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: outcomeMiss}))
})
})