feat(api): add /api/lidarr/search proxy with library/request enrichment
Implements GET /api/lidarr/search?q=&kind=artist|album|track. Validates kind and q, loads lidarr_config per-request, calls the matching Lidarr Lookup* method, enriches each result with in_library (EXISTS by MBID against artists/albums/tracks) and requested (HasNonTerminalRequestForMBID), and returns a normalized JSON array. Adds lidarrCfg field to handlers struct and threads lidarrconfig.Service through Mount and server.Router. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-7
@@ -13,15 +13,16 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64}
|
||||
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
@@ -51,14 +52,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/likes/albums", h.handleListLikedAlbums)
|
||||
authed.Get("/likes/artists", h.handleListLikedArtists)
|
||||
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
||||
|
||||
authed.Get("/lidarr/search", h.handleLidarrSearch)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
@@ -54,7 +55,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||
}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrconfig.New(pool)}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1})
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
)
|
||||
|
||||
// lidarrSearchResult is the JSON shape returned by GET /api/lidarr/search.
|
||||
// Field names follow the lowercase snake_case convention used by all /api/*
|
||||
// responses. artist_mbid and album_mbid are the parent identifiers surfaced
|
||||
// by LookupAlbum / LookupTrack; they are empty strings on artist results.
|
||||
type lidarrSearchResult struct {
|
||||
MBID string `json:"mbid"`
|
||||
Name string `json:"name"`
|
||||
SecondaryText string `json:"secondary_text"`
|
||||
ImageURL string `json:"image_url"`
|
||||
ArtistMBID string `json:"artist_mbid"`
|
||||
AlbumMBID string `json:"album_mbid"`
|
||||
InLibrary bool `json:"in_library"`
|
||||
Requested bool `json:"requested"`
|
||||
}
|
||||
|
||||
// handleLidarrSearch implements GET /api/lidarr/search?q=&kind=artist|album|track.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Validate kind and q params.
|
||||
// 2. Load lidarr config; 503 when disabled.
|
||||
// 3. Call the matching Lidarr lookup.
|
||||
// 4. For each result, compute in_library (EXISTS by MBID) and requested
|
||||
// (HasNonTerminalRequestForMBID).
|
||||
// 5. Return normalized JSON array.
|
||||
func (h *handlers) handleLidarrSearch(w http.ResponseWriter, r *http.Request) {
|
||||
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
||||
switch kind {
|
||||
case "artist", "album", "track":
|
||||
// valid
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "bad_kind", "kind must be artist, album, or track")
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeErr(w, http.StatusBadRequest, "missing_query", "q is required")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.lidarrCfg.Get(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("api: lidarr search: load config", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "failed to load lidarr config")
|
||||
return
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_disabled", "Lidarr integration is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||
|
||||
var results []lidarr.LookupResult
|
||||
switch kind {
|
||||
case "artist":
|
||||
results, err = client.LookupArtist(r.Context(), q)
|
||||
case "album":
|
||||
results, err = client.LookupAlbum(r.Context(), q)
|
||||
case "track":
|
||||
results, err = client.LookupTrack(r.Context(), q)
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarr.ErrUnreachable):
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_unreachable", "Lidarr is unreachable")
|
||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed", "Lidarr authentication failed")
|
||||
default:
|
||||
h.logger.Error("api: lidarr search: lookup failed", "kind", kind, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "Lidarr lookup failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dbQ := dbq.New(h.pool)
|
||||
out := make([]lidarrSearchResult, 0, len(results))
|
||||
for _, res := range results {
|
||||
inLib, libErr := h.lidarrInLibrary(r.Context(), kind, res.MBID)
|
||||
if libErr != nil {
|
||||
h.logger.Error("api: lidarr search: in_library check failed", "err", libErr)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "library check failed")
|
||||
return
|
||||
}
|
||||
requested, reqErr := dbQ.HasNonTerminalRequestForMBID(r.Context(), res.MBID)
|
||||
if reqErr != nil {
|
||||
h.logger.Error("api: lidarr search: requested check failed", "err", reqErr)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "request check failed")
|
||||
return
|
||||
}
|
||||
out = append(out, lidarrSearchResult{
|
||||
MBID: res.MBID,
|
||||
Name: res.Name,
|
||||
SecondaryText: res.Secondary,
|
||||
ImageURL: res.ImageURL,
|
||||
ArtistMBID: res.ArtistMBID,
|
||||
AlbumMBID: res.AlbumMBID,
|
||||
InLibrary: inLib,
|
||||
Requested: requested,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// lidarrInLibrary checks whether the given MBID exists in the corresponding
|
||||
// local library table based on kind. Runs a direct EXISTS query since
|
||||
// these per-kind MBID lookups are not in the sqlc-generated query set.
|
||||
func (h *handlers) lidarrInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
|
||||
var query string
|
||||
switch kind {
|
||||
case "artist":
|
||||
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
|
||||
case "album":
|
||||
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE mbid = $1)`
|
||||
case "track":
|
||||
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
)
|
||||
|
||||
// resetLidarrState clears lidarr_requests rows and resets lidarr_config
|
||||
// to the factory-default state (enabled=false, nulls) between tests.
|
||||
func resetLidarrState(t *testing.T, h *handlers) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := h.pool.Exec(ctx,
|
||||
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
|
||||
); err != nil {
|
||||
t.Fatalf("resetLidarrState: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL.
|
||||
func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) {
|
||||
t.Helper()
|
||||
svc := lidarrconfig.New(h.pool)
|
||||
err := svc.Save(context.Background(), lidarrconfig.Config{
|
||||
Enabled: enabled,
|
||||
BaseURL: baseURL,
|
||||
APIKey: "test-key",
|
||||
DefaultQualityProfileID: 1,
|
||||
DefaultRootFolderPath: "/music",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("saveLidarrConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lidarrArtistStubBody returns a minimal Lidarr artist lookup JSON payload.
|
||||
func lidarrArtistStubBody(mbid, name string) string {
|
||||
return fmt.Sprintf(`[{"foreignArtistId":%q,"artistName":%q,"genres":["Rock"],"albumCount":5,"images":[]}]`, mbid, name)
|
||||
}
|
||||
|
||||
// newLidarrStub creates an httptest.Server that always returns the given body
|
||||
// with 200 OK for any /api/v1/* path.
|
||||
func newLidarrStub(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// seedArtistWithMBID inserts an artist row with a specific MBID for in_library testing.
|
||||
func seedArtistWithMBID(t *testing.T, h *handlers, name, mbid string) dbq.Artist {
|
||||
t.Helper()
|
||||
a, err := dbq.New(h.pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: name,
|
||||
SortName: name,
|
||||
Mbid: &mbid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seedArtistWithMBID: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// seedRequestForMBID inserts a pending lidarr_requests row for the given MBID.
|
||||
func seedRequestForMBID(t *testing.T, h *handlers, userID pgtype.UUID, kind dbq.LidarrRequestKind, artistMBID, mbid string) {
|
||||
t.Helper()
|
||||
var albumMBID, trackMBID *string
|
||||
switch kind {
|
||||
case dbq.LidarrRequestKindAlbum:
|
||||
albumMBID = &mbid
|
||||
case dbq.LidarrRequestKindTrack:
|
||||
trackMBID = &mbid
|
||||
}
|
||||
_, err := dbq.New(h.pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
||||
UserID: userID,
|
||||
Kind: kind,
|
||||
LidarrArtistMbid: artistMBID,
|
||||
LidarrAlbumMbid: albumMBID,
|
||||
LidarrTrackMbid: trackMBID,
|
||||
ArtistName: "Test Artist",
|
||||
AlbumTitle: nil,
|
||||
TrackTitle: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seedRequestForMBID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// doLidarrSearch fires a GET /api/lidarr/search request directly at the handler.
|
||||
// It simulates the RequireUser middleware by injecting a dummy user context.
|
||||
func doLidarrSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", q, kind)
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
// Inject a minimal user context the same way other handler tests do it.
|
||||
// The lidarr search handler doesn't use the user from context, so any
|
||||
// non-nil user value keeps the handler from panicking.
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLidarrSearch(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_ValidationErrors covers bad kind and missing query
|
||||
// in a table-driven test.
|
||||
func TestHandleLidarrSearch_ValidationErrors(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q string
|
||||
kind string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "bad kind playlist",
|
||||
q: "Beatles",
|
||||
kind: "playlist",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantCode: "bad_kind",
|
||||
},
|
||||
{
|
||||
name: "missing query",
|
||||
q: "",
|
||||
kind: "artist",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantCode: "missing_query",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", tc.q, tc.kind)
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLidarrSearch(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %s", w.Code, tc.wantStatus, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if resp.Error.Code != tc.wantCode {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_DisabledReturns503 verifies that a disabled Lidarr
|
||||
// config causes the handler to return 503 lidarr_disabled.
|
||||
func TestHandleLidarrSearch_DisabledReturns503(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
// Config is already disabled after resetLidarrState; ensure it's explicit.
|
||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLidarrSearch(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if resp.Error.Code != "lidarr_disabled" {
|
||||
t.Errorf("error.code = %q, want lidarr_disabled", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_HappyPath_RequestableResult checks a result that is
|
||||
// neither in the library nor already requested.
|
||||
func TestHandleLidarrSearch_HappyPath_RequestableResult(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
const mbid = "artist-mbid-abc123"
|
||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "The Beatles"))
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
w := doLidarrSearch(t, h, "Beatles", "artist")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var results []lidarrSearchResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.MBID != mbid {
|
||||
t.Errorf("mbid = %q, want %q", r.MBID, mbid)
|
||||
}
|
||||
if r.InLibrary {
|
||||
t.Errorf("in_library = true, want false")
|
||||
}
|
||||
if r.Requested {
|
||||
t.Errorf("requested = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_HappyPath_InLibraryResult checks that a result whose
|
||||
// MBID matches an artist in the local DB has in_library=true.
|
||||
func TestHandleLidarrSearch_HappyPath_InLibraryResult(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
const mbid = "artist-mbid-inlib"
|
||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Library Artist"))
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
// Seed the artist with the matching MBID so in_library becomes true.
|
||||
seedArtistWithMBID(t, h, dbtest.TestUserPrefix+"LibraryArtist", mbid)
|
||||
|
||||
w := doLidarrSearch(t, h, "Library+Artist", "artist")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var results []lidarrSearchResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
if !results[0].InLibrary {
|
||||
t.Errorf("in_library = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_HappyPath_AlreadyRequested verifies that a result
|
||||
// whose MBID matches a pending lidarr_requests row has requested=true.
|
||||
func TestHandleLidarrSearch_HappyPath_AlreadyRequested(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
const mbid = "artist-mbid-requested"
|
||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Requested Artist"))
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
// Seed a user and a pending request for the artist MBID.
|
||||
user := seedUser(t, h.pool, "requester", "pw", false)
|
||||
seedRequestForMBID(t, h, user.ID, dbq.LidarrRequestKindArtist, mbid, mbid)
|
||||
|
||||
w := doLidarrSearch(t, h, "Requested+Artist", "artist")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var results []lidarrSearchResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
if !results[0].Requested {
|
||||
t.Errorf("requested = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLidarrSearch_LidarrUnreachableReturns503 verifies that a
|
||||
// connection-refused Lidarr base URL returns 503 lidarr_unreachable.
|
||||
func TestHandleLidarrSearch_LidarrUnreachableReturns503(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
// Port 1 is reserved and will refuse connections immediately.
|
||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
||||
// Use a short-deadline context so the test doesn't hang waiting for TCP.
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLidarrSearch(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if resp.Error.Code != "lidarr_unreachable" {
|
||||
t.Errorf("error.code = %q, want lidarr_unreachable", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||
@@ -55,7 +56,7 @@ func (s *Server) Router() http.Handler {
|
||||
s.EventsCfg.SkipMaxCompletionRatio,
|
||||
s.EventsCfg.SkipMaxDurationPlayedMs,
|
||||
)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrconfig.New(s.Pool))
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireUser(s.Pool))
|
||||
admin.Use(auth.RequireAdmin())
|
||||
|
||||
Reference in New Issue
Block a user