mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 20:07:11 +02:00
Moves the CLI's priority-name lookup into core/artwork so the native API and UI can render "scan" instead of a bare priority number, replaces a brittle string comparison with a presence check on the stored artwork, renders the source path in the details section, drops the unused hash field, and aligns date rendering with the rest of the dialog.
154 lines
6 KiB
Go
154 lines
6 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["chainOrigin"]).To(ContainSubstring("recorded"))
|
|
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{
|
|
"priority": float64(model.ArtworkPriorityScan),
|
|
"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["chainOrigin"]).To(Equal("not recorded"))
|
|
})
|
|
})
|