diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4bdd48422..303b64f5e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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) diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ae24b61fa..09229d5d7 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -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, diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 8ac8d9c9d..5027ca0b8 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -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{} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go index b60c1414c..885ca2b12 100644 --- a/core/artwork/trace_test.go +++ b/core/artwork/trace_test.go @@ -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})) + }) +})