mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
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:
parent
03743a030b
commit
b8c7f3cd2b
11 changed files with 267 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
146
server/nativeapi/artwork_explain.go
Normal file
146
server/nativeapi/artwork_explain.go
Normal 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
|
||||
}
|
||||
108
server/nativeapi/artwork_explain_test.go
Normal file
108
server/nativeapi/artwork_explain_test.go
Normal 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(®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"))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue