mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-21 02:39:16 -04:00
The explainable-kind list and the report's time format each existed twice, kept in sync by a comment pointing at the other copy. Both now live in core/artwork beside their siblings. FormatAgents returned a CLI-worded sentence, so the web UI rendered starred agents with no legend at all. It now reports a bool and each caller words its own note. Drop kind, id, chainOrigin and the numeric priority from the explain response: all four were serialized and never read, and chainOrigin is derivable from stored.attemptedAt on an endpoint that never walks. ExpandInfoDialog takes an optional resource instead of a node-or-map content prop. A page mounts one dialog per resource, which removes the silent blank-dialog failure mode when a map was missing a key. Outcome chips read the MUI palette rather than hardcoded hex, so they follow the dark theme.
153 lines
5.9 KiB
Go
153 lines
5.9 KiB
Go
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"}]`
|
|
gaveUpTrace := `[{"c":"external:deezer","o":"error","d":"connection reset"}]`
|
|
attemptedAt := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
|
Hash: "abc", Source: "external:deezer", SourcePath: "/music/Radiohead/folder.jpg", Trace: trace,
|
|
LastFailure: gaveUpTrace, AttemptedAt: attemptedAt,
|
|
})).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["config"]).To(HaveKeyWithValue("setting", "ArtistArtPriority"))
|
|
|
|
Expect(got["stored"]).To(Equal(map[string]any{
|
|
"source": "external:deezer",
|
|
"sourcePath": "/music/Radiohead/folder.jpg",
|
|
"attemptedAt": attemptedAt.Format(time.RFC3339),
|
|
}))
|
|
Expect(got["steps"]).To(Equal([]any{
|
|
map[string]any{"candidate": "external:deezer", "outcome": "hit", "detail": "https://cdn/x.jpg"},
|
|
}))
|
|
Expect(got["gaveUpAfter"]).To(Equal([]any{
|
|
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "connection reset"},
|
|
}))
|
|
})
|
|
|
|
It("returns the queue state and the last-attempt trace", func() {
|
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
|
Priority: model.ArtworkPriorityScan,
|
|
})).To(Succeed())
|
|
seen, err := queueRepo.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
retryAt := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
|
failTrace := `[{"c":"external:deezer","o":"error","d":"timeout"}]`
|
|
Expect(queueRepo.MarkFailedIfUnchanged(model.KindArtistArtwork.Prefix(), "ar-1", model.ImageTypePrimary,
|
|
seen.RetryAt, retryAt, failTrace)).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["queued"]).To(Equal(map[string]any{
|
|
"priorityName": "scan",
|
|
"attempts": float64(1),
|
|
"retryAt": retryAt.Format(time.RFC3339),
|
|
}))
|
|
Expect(got["lastAttemptFailed"]).To(Equal([]any{
|
|
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "timeout"},
|
|
}))
|
|
})
|
|
|
|
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).ToNot(HaveKey("lastAttemptFailed"))
|
|
Expect(got).ToNot(HaveKey("gaveUpAfter"))
|
|
Expect(got).ToNot(HaveKey("chainOrigin"))
|
|
})
|
|
})
|