feat(api): /api/discover/suggestions handler
This commit is contained in:
@@ -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("/tracks/{id}/stream", h.handleGetStream)
|
||||||
authed.Get("/search", h.handleSearch)
|
authed.Get("/search", h.handleSearch)
|
||||||
authed.Get("/radio", h.handleRadio)
|
authed.Get("/radio", h.handleRadio)
|
||||||
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||||
authed.Post("/events", h.handleEvents)
|
authed.Post("/events", h.handleEvents)
|
||||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user