diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index de4c55a1e..84bb34c9f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -79,7 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, agentsAgents) return router } diff --git a/server/nativeapi/artwork_explain.go b/server/nativeapi/artwork_explain.go new file mode 100644 index 000000000..ef3ab3987 --- /dev/null +++ b/server/nativeapi/artwork_explain.go @@ -0,0 +1,146 @@ +package nativeapi + +import ( + "encoding/json" + "errors" + "net/http" + "slices" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// explainableKinds is every kind the endpoint accepts, matching the CLI's explainKinds: a kind +// with no chain to walk still has stored state and config to report. +var explainableKinds = []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, + model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, +} + +type traceStepDTO struct { + Candidate string `json:"candidate"` + Outcome string `json:"outcome"` + Detail string `json:"detail,omitempty"` +} + +type storedDTO struct { + Source string `json:"source"` + Hash string `json:"hash,omitempty"` + SourcePath string `json:"sourcePath,omitempty"` + AttemptedAt string `json:"attemptedAt,omitempty"` +} + +type queuedDTO struct { + Priority int `json:"priority"` + Attempts int `json:"attempts"` + RetryAt string `json:"retryAt,omitempty"` +} + +type configDTO struct { + Setting string `json:"setting"` + Value string `json:"value"` +} + +type explainDTO struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Result string `json:"result"` + ChainOrigin string `json:"chainOrigin"` + Steps []traceStepDTO `json:"steps"` + Stored *storedDTO `json:"stored,omitempty"` + Queued *queuedDTO `json:"queued,omitempty"` + LastAttemptFailed []traceStepDTO `json:"lastAttemptFailed,omitempty"` + GaveUpAfter []traceStepDTO `json:"gaveUpAfter,omitempty"` + Config *configDTO `json:"config,omitempty"` + Agents string `json:"agents,omitempty"` +} + +func (api *Router) addArtworkExplainRoute(r chi.Router) { + r.Get("/artwork/explain", api.explainArtwork()) +} + +func (api *Router) explainArtwork() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind, ok := model.ParseKind(r.URL.Query().Get("kind")) + if !ok || !slices.Contains(explainableKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + id := r.URL.Query().Get("id") + + // No Walk: the endpoint reports history only, so it can never reach the network. + rep, err := artwork.Explain(ctx, api.ds, api.agents, kind, id, artwork.ExplainOptions{}) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error(ctx, "Error explaining artwork", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(toExplainDTO(rep)); err != nil { + log.Error(ctx, "Error encoding artwork explain response", "kind", kind, "id", id, err) + } + } +} + +func rfc3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func toStepDTOs(steps []artwork.TraceStep) []traceStepDTO { + // Never nil: the UI maps over this unconditionally. + out := make([]traceStepDTO, 0, len(steps)) + for _, s := range steps { + out = append(out, traceStepDTO{Candidate: s.Candidate, Outcome: string(s.Outcome), Detail: s.Detail}) + } + return out +} + +func toExplainDTO(rep artwork.ExplainReport) explainDTO { + dto := explainDTO{ + Kind: rep.Kind.Prefix(), + ID: rep.ID, + Name: rep.Name, + Result: rep.Result(), + ChainOrigin: rep.ChainOrigin(), + Steps: toStepDTOs(rep.Steps), + Agents: rep.Agents, + } + if rep.Stored != nil { + dto.Stored = &storedDTO{ + Source: rep.Stored.Source, + Hash: rep.Stored.Hash, + SourcePath: rep.Stored.SourcePath, + AttemptedAt: rfc3339(rep.Stored.AttemptedAt), + } + } + if rep.Queued != nil { + dto.Queued = &queuedDTO{ + Priority: rep.Queued.Priority, + Attempts: rep.Queued.Attempts, + RetryAt: rfc3339(rep.Queued.RetryAt), + } + } + if steps := rep.LastAttemptFailed(); len(steps) > 0 { + dto.LastAttemptFailed = toStepDTOs(steps) + } + if steps := rep.GaveUpAfter(); len(steps) > 0 { + dto.GaveUpAfter = toStepDTOs(steps) + } + if setting, value := artwork.ConfigFor(rep.Kind); setting != "" { + dto.Config = &configDTO{Setting: setting, Value: value} + } + return dto +} diff --git a/server/nativeapi/artwork_explain_test.go b/server/nativeapi/artwork_explain_test.go new file mode 100644 index 000000000..d316a3357 --- /dev/null +++ b/server/nativeapi/artwork_explain_test.go @@ -0,0 +1,108 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GET /artwork/explain", func() { + var router http.Handler + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + conf.Server.ArtistArtPriority = "external" + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + Expect(ds.Artist(context.Background()).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + auth.Init(ds) + + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.Background()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.Background()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns 403 for a non-admin", func() { + req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("returns 400 for an unknown kind", func() { + req := createAuthenticatedRequest("GET", "/artwork/explain?kind=zz&id=ar-1", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 for an unknown id", func() { + req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=missing", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns the report for an admin", func() { + // Storage shape from core/artwork/trace.go's storedStep: single-letter keys, "d" optional. + trace := `[{"c":"external:deezer","o":"hit","d":"https://cdn/x.jpg"}]` + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary, + Hash: "abc", Source: "external:deezer", Trace: trace, + AttemptedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC), + })).To(Succeed()) + + req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + + var got map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed()) + Expect(got["name"]).To(Equal("Radiohead")) + Expect(got["result"]).To(Equal("resolved from external:deezer")) + Expect(got["chainOrigin"]).To(ContainSubstring("recorded")) + Expect(got["steps"]).To(HaveLen(1)) + Expect(got["config"]).To(HaveKeyWithValue("setting", "ArtistArtPriority")) + }) + + It("omits empty sections", func() { + req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + + var got map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed()) + Expect(got).ToNot(HaveKey("stored")) + Expect(got).ToNot(HaveKey("queued")) + Expect(got["chainOrigin"]).To(Equal("not recorded")) + }) +}) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d1007f457..7c4f00fdd 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 13b33c238..cef2e06ad 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go index ebc9aeb28..ae6e301a8 100644 --- a/server/nativeapi/metadata_test.go +++ b/server/nativeapi/metadata_test.go @@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() { } auth.Init(ds) provider = &fakeProvider{} - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) router = server.JWTVerifier(nativeRouter) adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 57a712a20..b3b7374f9 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" @@ -48,10 +49,11 @@ type Router struct { pluginManager PluginManager imgUpload artwork.Uploader provider external.Provider + agents *agents.Agents } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, ag *agents.Agents) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, agents: ag} r.Handler = r.routes() return r } @@ -95,6 +97,7 @@ func (api *Router) routes() http.Handler { api.addUserLibraryRoute(r) api.addPluginRoute(r) api.addMetadataRoute(r) + api.addArtworkExplainRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index 203fcd4cf..954c872a7 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9abcc477f..e9ca26d7f 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 1683885e7..4e45ddb92 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 2a363980f..27454fc7d 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) })