From ae2d69d37879e4ba8ff9fdea34e064a47fc0f3bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 06:23:08 -0400 Subject: [PATCH] feat(api): /api/discover/suggestions handler --- internal/api/api.go | 1 + internal/api/suggestions.go | 85 +++++++++++++++++++++++++ internal/api/suggestions_test.go | 104 +++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 internal/api/suggestions.go create mode 100644 internal/api/suggestions_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 0ef8d086..fa2707f7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -49,6 +49,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/tracks/{id}/stream", h.handleGetStream) authed.Get("/search", h.handleSearch) authed.Get("/radio", h.handleRadio) + authed.Get("/discover/suggestions", h.handleListSuggestions) authed.Post("/events", h.handleEvents) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) diff --git a/internal/api/suggestions.go b/internal/api/suggestions.go new file mode 100644 index 00000000..c4ab3134 --- /dev/null +++ b/internal/api/suggestions.go @@ -0,0 +1,85 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// suggestionView is the wire shape returned by GET /api/discover/suggestions. +type suggestionView struct { + MBID string `json:"mbid"` + Name string `json:"name"` + Score float64 `json:"score"` + Attribution []seedContributionView `json:"attribution"` +} + +type seedContributionView struct { + ArtistID pgtype.UUID `json:"artist_id"` + Name string `json:"name"` + Contribution float64 `json:"contribution"` + IsLiked bool `json:"is_liked"` + PlayCount int64 `json:"play_count"` +} + +// handleListSuggestions implements GET /api/discover/suggestions. +// +// Query params: +// - limit (default 12, capped at 50) +// - half_life_days (default 30) +// +// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate. +func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + limit := 12 + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + halfLife := 30.0 + if v := r.URL.Query().Get("half_life_days"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil || f <= 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid half_life_days") + return + } + halfLife = f + } + + suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) + if err != nil { + h.logger.Error("api: list suggestions", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "failed to load suggestions") + return + } + + out := make([]suggestionView, 0, len(suggestions)) + for _, s := range suggestions { + attr := make([]seedContributionView, 0, len(s.Attribution)) + for _, a := range s.Attribution { + attr = append(attr, seedContributionView{ + ArtistID: a.ArtistID, + Name: a.Name, + Contribution: a.Contribution, + IsLiked: a.IsLiked, + PlayCount: a.PlayCount, + }) + } + out = append(out, suggestionView{ + MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, + }) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/suggestions_test.go b/internal/api/suggestions_test.go new file mode 100644 index 00000000..be7bb5de --- /dev/null +++ b/internal/api/suggestions_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newSuggestionsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/discover/suggestions", h.handleListSuggestions) + return r +} + +func doListSuggestions(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder { + url := "/api/discover/suggestions" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newSuggestionsRouter(h).ServeHTTP(w, req) + return w +} + +func TestSuggestions_HappyPath(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + q := dbq.New(pool) + seed, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Seed", SortName: "Seed", + }) + if err != nil { + t.Fatalf("UpsertArtist: %v", err) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`, + user.ID, seed.ID, + ); err != nil { + t.Fatalf("like artist: %v", err) + } + if err := q.UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: seed.ID, + CandidateMbid: "out-mbid", + CandidateName: "Outsider", + Score: 0.9, + Source: "listenbrainz", + }); err != nil { + t.Fatalf("UpsertArtistSimilarityUnmatched: %v", err) + } + + w := doListSuggestions(h, user, "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got []suggestionView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1; body = %s", len(got), w.Body.String()) + } + if got[0].MBID != "out-mbid" || got[0].Name != "Outsider" { + t.Errorf("got = %+v", got[0]) + } + if len(got[0].Attribution) != 1 || got[0].Attribution[0].Name != "Seed" { + t.Errorf("attribution = %+v, want one entry named Seed", got[0].Attribution) + } + if !got[0].Attribution[0].IsLiked { + t.Errorf("attribution.IsLiked = false, want true") + } +} + +func TestSuggestions_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "newbie", "pw", false) + + w := doListSuggestions(h, user, "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if body != "[]" && body != "[]\n" { + t.Errorf("body = %q, want []", body) + } +} + +func TestSuggestions_BadLimit(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doListSuggestions(h, user, "limit=not-a-number") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } +}