feat(api): add /api/radio stub returning the seed track only

M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final.
This commit is contained in:
2026-04-25 15:01:39 -04:00
parent 8fd193e186
commit 1e66910823
3 changed files with 151 additions and 0 deletions
+1
View File
@@ -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)
})
})
}
+60
View File
@@ -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=<uuid>.
//
// 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)},
})
}
+90
View File
@@ -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)
}
}