feat(nativeapi): add an admin-only artwork explain endpoint

Exposes core/artwork.Explain over GET /api/artwork/explain?kind=&id=,
reporting stored trace and queue state without ever walking the chain
live, so the response can only leak history the server already has.
This commit is contained in:
Deluan 2026-09-03 23:19:49 -04:00
commit b8c7f3cd2b
11 changed files with 267 additions and 10 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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(&regularUser)).To(Succeed())
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
userToken, err = auth.CreateToken(&regularUser)
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"))
})
})

View file

@ -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

View file

@ -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

View file

@ -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"}

View file

@ -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)
})
})

View file

@ -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()
})

View file

@ -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()
})

View file

@ -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

View file

@ -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)
})