diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 851e52b7..5ab5599b 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -52,7 +52,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) recCfg := config.RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, + SkipPenalty: 1.0, JitterMagnitude: 0.1, + 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 }} diff --git a/internal/api/radio.go b/internal/api/radio.go index dd02b4c1..7fa2e228 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -82,19 +82,33 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) + exclude := parseExcludeParam(r.URL.Query().Get("exclude")) + limits := recommendation.DefaultCandidateSourceLimits() + candidates, err := recommendation.LoadCandidatesFromSimilarity( + r.Context(), q, user.ID, seedID, + h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits, + ) if err != nil { - h.logger.Error("api: radio: load candidates", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") - return + h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err) + candidates, err = recommendation.LoadCandidates( + r.Context(), q, user.ID, seedID, + h.recCfg.RecentlyPlayedHours, currentVec, + ) + if err != nil { + h.logger.Error("api: radio: load candidates fallback failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") + return + } } + weights := recommendation.ScoringWeights{ - BaseWeight: h.recCfg.BaseWeight, - LikeBoost: h.recCfg.LikeBoost, - RecencyWeight: h.recCfg.RecencyWeight, - SkipPenalty: h.recCfg.SkipPenalty, - JitterMagnitude: h.recCfg.JitterMagnitude, - ContextWeight: h.recCfg.ContextWeight, + BaseWeight: h.recCfg.BaseWeight, + LikeBoost: h.recCfg.LikeBoost, + RecencyWeight: h.recCfg.RecencyWeight, + SkipPenalty: h.recCfg.SkipPenalty, + JitterMagnitude: h.recCfg.JitterMagnitude, + ContextWeight: h.recCfg.ContextWeight, + SimilarityWeight: h.recCfg.SimilarityWeight, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) @@ -141,3 +155,26 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI } return v } + +// parseExcludeParam parses a comma-separated list of UUIDs from the +// `exclude` query string, silently dropping malformed entries. Returns +// nil for empty or all-malformed input. +func parseExcludeParam(raw string) []pgtype.UUID { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]pgtype.UUID, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + id, ok := parseUUID(p) + if !ok { + continue + } + out = append(out, id) + } + return out +} diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 9002efeb..f81c7a0b 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -131,6 +131,110 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) { } } +func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000) + control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000) + if _, err := pool.Exec(context.Background(), + `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`, + seed.ID, target.ID); err != nil { + t.Fatalf("insert sim: %v", err) + } + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3") + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + targetIdx, controlIdx := -1, -1 + for i, tr := range resp.Tracks { + if tr.ID == uuidToString(target.ID) { + targetIdx = i + } + if tr.ID == uuidToString(control.ID) { + controlIdx = i + } + } + if targetIdx < 0 || controlIdx < 0 { + t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx) + } + if targetIdx >= controlIdx { + t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx) + } +} + +func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000) + for i := 3; i <= 6; i++ { + seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) + } + q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID) + w := callRadio(h, user, q) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + for _, tr := range resp.Tracks { + if tr.ID == uuidToString(excluded.ID) { + t.Error("excluded track present in response") + } + } +} + +func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) + q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID) + w := callRadio(h, user, q) + if w.Code != http.StatusOK { + t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + for _, tr := range resp.Tracks { + if tr.ID == uuidToString(other.ID) { + t.Error("other track should still be excluded after dropping malformed entry") + } + } +} + +func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) { + // Defensive: even when the similarity pool returns no candidates, + // the seed track must still be the first track in the response. + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) { + t.Errorf("seed not at index 0: %v", resp.Tracks) + } +} + func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool)