diff --git a/internal/api/api.go b/internal/api/api.go index 2e0c6eb9..8c18e6ee 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -32,6 +32,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) { authed.Get("/tracks/{id}", h.handleGetTrack) authed.Get("/tracks/{id}/stream", h.handleGetStream) authed.Get("/search", h.handleSearch) + authed.Get("/radio", h.handleRadio) }) }) } diff --git a/internal/api/radio.go b/internal/api/radio.go new file mode 100644 index 00000000..d7d70e5d --- /dev/null +++ b/internal/api/radio.go @@ -0,0 +1,60 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// RadioResponse is the body of GET /api/radio. +type RadioResponse struct { + Tracks []TrackRef `json:"tracks"` +} + +// handleRadio implements GET /api/radio?seed_track=. +// +// M6 stub: returns a queue containing only the seed track. The full M4 +// implementation (similarity-based candidate pool + scoring) replaces the +// body without changing the request/response shape. +func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) + if raw == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") + return + } + id, ok := parseUUID(raw) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") + return + } + q := dbq.New(h.pool) + track, err := q.GetTrackByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") + return + } + h.logger.Error("api: get radio seed track failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + album, err := q.GetAlbumByID(r.Context(), track.AlbumID) + if err != nil { + h.logger.Error("api: get radio seed album failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + artist, err := q.GetArtistByID(r.Context(), track.ArtistID) + if err != nil { + h.logger.Error("api: get radio seed artist failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + writeJSON(w, http.StatusOK, RadioResponse{ + Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)}, + }) +} diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go new file mode 100644 index 00000000..58964c85 --- /dev/null +++ b/internal/api/radio_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newRadioRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/radio", h.handleRadio) + return r +} + +func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + artist := seedArtist(t, pool, "Beatles") + album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) + track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) + + req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track="+uuidToString(track.ID), nil) + w := httptest.NewRecorder() + newRadioRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var got struct { + Tracks []TrackRef `json:"tracks"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Tracks) != 1 { + t.Fatalf("len=%d, want 1", len(got.Tracks)) + } + if got.Tracks[0].Title != "Something" { + t.Errorf("title=%q", got.Tracks[0].Title) + } + if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" { + t.Errorf("ref = %+v", got.Tracks[0]) + } +} + +func TestHandleRadio_NotFound(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + + req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil) + w := httptest.NewRecorder() + newRadioRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d", w.Code) + } +} + +func TestHandleRadio_MissingSeed(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/radio", nil) + w := httptest.NewRecorder() + newRadioRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status=%d, want 400", w.Code) + } +} + +func TestHandleRadio_BlankSeed(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil) + w := httptest.NewRecorder() + newRadioRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status=%d, want 400", w.Code) + } +} + +func TestHandleRadio_BadUUID(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil) + w := httptest.NewRecorder() + newRadioRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status=%d, want 400", w.Code) + } +}