mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
feat(artwork): expose a tracing resolver for the CLI
This commit is contained in:
parent
3eeb453aba
commit
e3c78a9cb1
4 changed files with 185 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue