diff --git a/internal/api/api.go b/internal/api/api.go index 31463b2a..2f5281bf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b13b455e..87e24cc8 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 9c8e7714..6a76a0ae 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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", diff --git a/internal/api/lidarr.go b/internal/api/lidarr.go new file mode 100644 index 00000000..b7b3218b --- /dev/null +++ b/internal/api/lidarr.go @@ -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 +} + diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go new file mode 100644 index 00000000..591cf578 --- /dev/null +++ b/internal/api/lidarr_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9358e8d2..bbe77125 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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())