Merge pull request 'feat: M3 weighted shuffle v1 — real /api/radio with scoring' (#21) from dev into main
This commit was merged in pull request #21.
This commit is contained in:
@@ -75,7 +75,7 @@ func run() error {
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events)
|
||||
}, cfg.Events, cfg.Recommendation)
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
|
||||
@@ -45,3 +45,21 @@ events:
|
||||
# this threshold AND duration played (ms) is below the next threshold.
|
||||
skip_max_completion_ratio: 0.5
|
||||
skip_max_duration_played_ms: 30000
|
||||
|
||||
recommendation:
|
||||
# Base score every candidate gets before adjustments. Spec §6.
|
||||
base_weight: 1.0
|
||||
# Bonus for tracks the user has liked (general_likes).
|
||||
like_boost: 2.0
|
||||
# Multiplier on recency_decay (1.0 for tracks ≥ 30 days stale, 0 for fresh).
|
||||
recency_weight: 1.0
|
||||
# Penalty multiplier on skip_ratio (skips/plays); 1.0 = full penalty.
|
||||
skip_penalty: 1.0
|
||||
# ± random jitter applied to every candidate; breaks ties without dominating.
|
||||
jitter_magnitude: 0.1
|
||||
# Hard-suppression window: tracks played within this many hours never appear.
|
||||
recently_played_hours: 1
|
||||
# Default radio size when ?limit= is not specified.
|
||||
radio_size: 50
|
||||
# Maximum allowed ?limit= (server caps to this regardless).
|
||||
radio_size_max: 200
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
# M3 Weighted Shuffle v1 — Design Spec
|
||||
|
||||
**Status:** approved 2026-04-27
|
||||
**Slice:** M3 sub-plan #1 of 3 (recommendation engine v1 + contextual likes). Spec §13 step 7. Step 8 (session vectors + contextual_match_score) is the next sub-plan; this slice ships the scoring foundation without the contextual term.
|
||||
**Fable task:** #340.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. After this slice, clicking a track in the SPA's search/track-row "play radio" surface produces a meaningful 50-track queue instead of a one-track stub.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Session vectors / `contextual_match_score` (sub-plan #2 / Fable #341 covers the write path; sub-plan #3 / Fable #342 folds the score into this function).
|
||||
- Real "radio" candidate pools from ListenBrainz similarity / similar artists / similar tags (M4).
|
||||
- Album-seed or artist-seed radio (`?seed_album=`, `?seed_artist=`). v1 is track-seeded only; the Fable task covers that scope explicitly.
|
||||
- Periodic radio refresh (the spec's "regenerate at 80% consumed"). Player-side concern; lands later.
|
||||
- Persistent radio queues. Each `/api/radio` call returns a fresh selection — stateless on the server.
|
||||
- Performance optimization for very large libraries (>50k tracks). At v1 scale the wide-pool scan is fine; M3.5 / M4 can add sampling if telemetry shows it matters.
|
||||
- `Subsonic` star-radio compatibility. Subsonic's `getRandomSongs` is a different shape; we don't try to map weighted shuffle into it for v1.
|
||||
|
||||
## Architecture
|
||||
|
||||
A new `internal/recommendation` package owns:
|
||||
|
||||
- A pure scoring function `Score(inputs, weights, now, rng) → float64` with no DB dependency. The `rng` parameter is injectable so tests pin jitter to deterministic values.
|
||||
- A candidate loader `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` that runs ONE SQL query joining `tracks`, `general_likes`, and aggregated `play_events` to produce the per-track stats the scoring function needs. Excludes the seed and any track played within the recently-played window.
|
||||
- A `Shuffle(candidates, weights, now, rng, limit)` orchestrator that scores each candidate, sorts descending, truncates to `limit`. Pure.
|
||||
|
||||
The `/api/radio` handler becomes a thin shim: validate the seed → call `LoadCandidates` → call `Shuffle` → prepend the seed to the result → project to `[]TrackRef` → return JSON.
|
||||
|
||||
**Why split into three layers:**
|
||||
- `score.go` is the math; trivially unit-testable, no infra.
|
||||
- `candidates.go` is the data access; integration-tested against a live DB.
|
||||
- `shuffle.go` is the composition; pure-function tests against fake candidate sets.
|
||||
- The HTTP handler is the I/O glue; lives in `api/radio.go` like the existing pattern.
|
||||
|
||||
This separation also sets up sub-plan #3 (contextual scoring) cleanly — we'll add `LoadContextualSimilarity` next to `LoadCandidates`, extend `ScoringInputs` with `ContextualMatchScore`, and the rest of the chain doesn't change.
|
||||
|
||||
## Schema
|
||||
|
||||
No migration. All inputs are computed on demand from existing tables:
|
||||
|
||||
- `general_likes` for `is_general_liked`.
|
||||
- `play_events` for `last_played_at`, `play_count`, `skip_count`. Aggregations done in the candidate-loader query.
|
||||
- `tracks` provides the candidate set itself.
|
||||
|
||||
The recently-played-in-the-last-hour filter is applied at candidate-load time (a single `WHERE NOT EXISTS (... AND started_at > now() - interval '1 hour')` clause). Hard suppression — these tracks aren't even scored.
|
||||
|
||||
## Scoring math
|
||||
|
||||
```go
|
||||
type ScoringInputs struct {
|
||||
IsGeneralLiked bool
|
||||
LastPlayedAt *time.Time // nil = never played
|
||||
PlayCount int // total play_events
|
||||
SkipCount int // play_events with was_skipped=true
|
||||
}
|
||||
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64 // default 1.0
|
||||
LikeBoost float64 // default 2.0
|
||||
RecencyWeight float64 // default 1.0
|
||||
SkipPenalty float64 // default 1.0
|
||||
JitterMagnitude float64 // default 0.1
|
||||
}
|
||||
|
||||
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
|
||||
s := w.BaseWeight
|
||||
if in.IsGeneralLiked {
|
||||
s += w.LikeBoost
|
||||
}
|
||||
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
**`recencyDecay(lastPlayed *time.Time, now time.Time) float64`** — returns `[0, 1]`:
|
||||
- Never played (`lastPlayed == nil`) → `1.0`. Cold-start tracks compete favorably with stale ones; otherwise the recommendation engine never surfaces music the user hasn't tried.
|
||||
- Played within last hour: doesn't reach this function (filtered earlier at the candidate-pool stage).
|
||||
- Otherwise: `min(age_days / 30.0, 1.0)`. Linear ramp; tracks ≥ 30 days stale hit max recency boost.
|
||||
|
||||
**`skipRatio(plays, skips int) float64`** — returns `[0, 1]`:
|
||||
- `plays == 0` → `0.0`. Don't penalize never-played tracks.
|
||||
- Otherwise → `float64(skips) / float64(plays)`.
|
||||
|
||||
**Random jitter** — `(rng()*2 - 1) * JitterMagnitude` produces values in `[-magnitude, +magnitude]`. The `rng` parameter is `func() float64` (matches `math/rand.Float64`); tests inject a fixed-value version for deterministic ordering.
|
||||
|
||||
**Score range under defaults:**
|
||||
- Min (unliked, recent, all-skips): `1.0 + 0 + 0 - 1.0 - 0.1 = -0.1`
|
||||
- Max (liked, ≥30d stale, never skipped): `1.0 + 2.0 + 1.0 - 0 + 0.1 = 4.1`
|
||||
- Liked-track structural advantage (`LikeBoost = 2.0`) dominates the jitter band (`±0.1`), so liked tracks always rank above identical unliked tracks.
|
||||
|
||||
Configurable via YAML / env. Operators can crank `LikeBoost` up if their library is dominated by stuff they don't actually like, or `JitterMagnitude` up if they want more variety.
|
||||
|
||||
## API contracts
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/radio?seed_track=<uuid>&limit=<int>
|
||||
```
|
||||
|
||||
- `seed_track` (required) — UUID of the track that seeds the radio.
|
||||
- `limit` (optional, default 50, max 200) — total number of tracks to return *including* the seed.
|
||||
|
||||
**Response (shape unchanged from M2 stub, only contents grew):**
|
||||
|
||||
```ts
|
||||
type RadioResponse = { tracks: TrackRef[] };
|
||||
```
|
||||
|
||||
- `tracks[0]` is always the seed track (so `playQueue(resp.tracks, 0)` plays the seed).
|
||||
- `tracks[1..]` are the top-scored candidates from the user's library, descending. Length up to `limit - 1`.
|
||||
- Cold-start (empty library beyond the seed): returns `{ tracks: [<seed>] }`.
|
||||
|
||||
**Errors (unchanged from M2 stub):**
|
||||
- `400 bad_request` — missing `seed_track`, malformed UUID, or `limit < 1`.
|
||||
- `404 not_found` — `seed_track` doesn't exist.
|
||||
- `500 server_error` — DB issue.
|
||||
|
||||
**No web client changes.** The existing `playRadio(seedTrackId)` already calls this endpoint and feeds `resp.tracks` into `playQueue(tracks, 0)`. After this slice the queue is fuller; the call shape is identical.
|
||||
|
||||
**No Subsonic changes.** Track-seeded radio isn't part of Subsonic's surface in our v1 scope.
|
||||
|
||||
## Components & files
|
||||
|
||||
### New server files
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `internal/recommendation/score.go` | Pure scoring function + `recencyDecay` + `skipRatio` helpers. No DB. |
|
||||
| `internal/recommendation/score_test.go` | Boundary cases: every term, cold-start, jitter determinism, score ranges. |
|
||||
| `internal/recommendation/candidates.go` | `LoadCandidates(...)` — single SQL query returning `[]Candidate` (`Track + ScoringInputs`). |
|
||||
| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. |
|
||||
| `internal/recommendation/shuffle.go` | `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. Pure. |
|
||||
| `internal/recommendation/shuffle_test.go` | Pure-function tests: liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder-structural-winners, limit. |
|
||||
| `internal/db/queries/recommendation.sql` | sqlc query: `LoadRadioCandidates` — SELECT tracks WITH LEFT JOIN general_likes, LEFT JOIN aggregated play_events stats. WHERE clause excludes seed_id and last-hour plays. |
|
||||
| `internal/db/dbq/recommendation.sql.go` | Generated bindings. |
|
||||
|
||||
### Modified server files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `internal/api/radio.go` | Replace stub. New flow: validate `seed_track` and `limit`, call `recommendation.LoadCandidates`, call `recommendation.Shuffle`, prepend seed, project to `[]TrackRef`, return JSON. |
|
||||
| `internal/api/radio_test.go` | Replace stub tests with: cold-start, typical (seed + scored picks), 404 on unknown seed, 400 on bad seed/limit, cross-user isolation. |
|
||||
| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` struct: `BaseWeight` (1.0), `LikeBoost` (2.0), `RecencyWeight` (1.0), `SkipPenalty` (1.0), `JitterMagnitude` (0.1), `RecentlyPlayedHours` (1), `RadioSize` (50), `RadioSizeMax` (200). YAML key `recommendation:`. |
|
||||
| `internal/api/api.go` | `handlers` struct gains `recCfg config.RecommendationConfig`. `Mount` signature gains the config arg; constructs the handler with it; the radio handler builds `ScoringWeights` from it per request. |
|
||||
| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. |
|
||||
|
||||
### No web changes
|
||||
|
||||
The existing `playRadio(seedTrackId)` already consumes `RadioResponse`. The web slice for this PR is empty — server-only.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. SPA calls `playRadio(seedTrackId)` (existing player-store action). It POSTs `GET /api/radio?seed_track=<id>` (no explicit `limit`, server defaults to 50).
|
||||
2. `handleRadio` validates auth + seed UUID. Looks up the track to confirm it exists (404 otherwise).
|
||||
3. Reads `limit` from query (default `cfg.Recommendation.RadioSize`, clamped to `cfg.Recommendation.RadioSizeMax`).
|
||||
4. Calls `recommendation.LoadCandidates(ctx, q, userID, seedID, cfg.Recommendation.RecentlyPlayedHours)`. The SQL query joins:
|
||||
- `tracks t` — base set
|
||||
- `LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id` → `is_liked`
|
||||
- `LEFT JOIN LATERAL (SELECT max(started_at) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_last → last_played_at`
|
||||
- `LEFT JOIN LATERAL (SELECT count(*), count(*) FILTER (WHERE was_skipped) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_stats → play_count, skip_count`
|
||||
- `WHERE t.id <> seed_id AND NOT EXISTS (SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - interval '$2 hours')`
|
||||
5. Iterates the candidates, calls `Score(...)` per track, sorts descending, truncates to `limit - 1`.
|
||||
6. Prepends the seed track, projects each `Candidate.Track` to `TrackRef` (existing `trackRefFrom` helper), writes `RadioResponse{ Tracks: ... }`.
|
||||
7. SPA receives 50 tracks, plays from index 0 (the seed).
|
||||
|
||||
**Stateless** — every call recomputes from scratch. No persisted radio queue, no cursor tracking, no "you already heard this" memory beyond the recently-played-hours window.
|
||||
|
||||
## Testing
|
||||
|
||||
### Server (`go test`)
|
||||
|
||||
**`internal/recommendation/score_test.go`** (pure unit tests):
|
||||
- Base case (never played, not liked, no skip data) with deterministic RNG `() => 0.5` → score = `BaseWeight + RecencyWeight + 0` exactly.
|
||||
- Liked boost: same inputs but `IsGeneralLiked=true` → score increases by `LikeBoost`.
|
||||
- Recency ramp: `lastPlayedAt = now - 15d` → `recencyDecay = 0.5`. `lastPlayedAt = now - 60d` → `1.0` (capped).
|
||||
- Skip ratio: `PlayCount=4, SkipCount=2` → ratio `0.5`, score loses `0.5 * SkipPenalty`.
|
||||
- Cold-start skip: `PlayCount=0, SkipCount=0` → ratio `0.0`, no penalty.
|
||||
- Jitter bounds: 1000 `Score(...)` calls with `math/rand.Float64`, every result within `[mid - JitterMagnitude, mid + JitterMagnitude]` where `mid` is the deterministic-RNG score.
|
||||
- Determinism: same `(inputs, weights, now, rng)` returns the same score (regression guard for hidden global state).
|
||||
|
||||
**`internal/recommendation/shuffle_test.go`** (pure unit tests):
|
||||
- Liked-vs-not: two otherwise-identical candidates → liked one ranks higher.
|
||||
- High-skip rejected: candidate with `skipRatio=1.0` ranks last among otherwise-identical candidates.
|
||||
- Limit truncates: 100 candidates, `limit=10` → result has 10.
|
||||
- Jitter doesn't reorder structural winners: 100 random RNG seeds → liked track ALWAYS ranks above an equivalent unliked track.
|
||||
- Empty input → empty output.
|
||||
|
||||
**`internal/recommendation/candidates_test.go`** (live DB):
|
||||
- Seed exclusion: 5 tracks in library, ask for radio with one as seed → returns the other 4.
|
||||
- Recently-played exclusion: seed a `play_events` row with `started_at = now - 30 minutes` for track A → radio excludes A.
|
||||
- Stat join: track with one play + one skip → `play_count=1, skip_count=1`. Liked track → `is_liked=true`. Never-played → `last_played_at IS NULL`.
|
||||
- Cross-user: Alice's plays / likes do NOT appear in Bob's stats.
|
||||
|
||||
**`internal/api/radio_test.go`** (HTTP integration, replacing existing stub tests):
|
||||
- Cold-start: seed only in library → response is `{ tracks: [seed] }`.
|
||||
- Typical: seed + 5 other tracks → response is `[seed, 5 ranked]`. Seed always at index 0.
|
||||
- 404 on unknown seed.
|
||||
- 400 on missing/malformed seed.
|
||||
- 400 on `limit=0` or `limit < 0`.
|
||||
- `limit > RadioSizeMax` clamped (returns at most `RadioSizeMax`, no error).
|
||||
- Cross-user isolation: Alice has many plays + likes → Bob's radio uses Bob's clean stats.
|
||||
|
||||
**Coverage:** `go test -coverprofile=cover.out ./internal/recommendation/...` → ≥ 70% per the M3 milestone description. The pure-function tests should hit ~100% on `score.go` + `shuffle.go`; `candidates.go` reaches similar coverage via the integration tests.
|
||||
|
||||
### End-to-end manual
|
||||
|
||||
Final task in the implementation plan:
|
||||
|
||||
1. Sign in. Play 3 tracks all the way through (build `play_count` history).
|
||||
2. Skip a 4th track within 10 seconds (creates a high `skip_ratio`).
|
||||
3. Like 2 tracks via the heart button.
|
||||
4. Click radio on a 5th track.
|
||||
5. Inspect the response in dev tools network tab — 50 tracks, seed first.
|
||||
6. The 2 liked tracks appear early in the list (within first ~20).
|
||||
7. The just-skipped track appears late or absent.
|
||||
8. The 3 just-played tracks are absent (recently-played exclusion).
|
||||
9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **Wide-pool scan performance.** For a 50k-track library: 50k rows × scoring overhead × sort ≈ a few hundred ms in Go. Below the user-perceptible threshold for a click-to-play. If telemetry later shows >1s P95 latency, M3.5 / M4 can add sampling (random-N candidate pre-filter) or a partial index. The candidate-pool function is the swap point; the scoring function and HTTP handler don't change.
|
||||
- **Recently-played window edge effects.** If the user plays 50 tracks in an hour, all of them get excluded from the next radio call. Library < 100 tracks could leave the candidate pool too thin to fill `RadioSize`. Mitigation: handler returns whatever it can fit (length might be < `RadioSize`); SPA's `playQueue` works on any non-empty array. Document as acceptable degradation; users with tiny libraries get short radios.
|
||||
- **Skip-ratio gaming.** A user who skips a track once (during initial library discovery) gets a `skipRatio=1.0` for that track. With `SkipPenalty=1.0` that's a `-1.0` hit — could permanently demote the track even if they like it. Mitigation: weights are tunable; user can lower `SkipPenalty`. Future: smoothing (e.g. `(skips + α) / (plays + β)`) instead of raw ratio. Out of scope for v1.
|
||||
- **Cold-start RNG dominance.** A brand-new user with zero plays / likes gets every track scored at `BaseWeight + RecencyWeight + jitter`. Top-N is essentially random. That's fine — it matches user expectation ("I haven't told you anything about me, give me random music"). The recency-decay max-for-never-played avoids amplifying new tracks just because they're new.
|
||||
- **Score formula evolution.** Adding `contextual_match_score` in sub-plan #3 means changing `ScoringInputs` and the `Score` function signature. Mitigation: the function is internal-package only; sub-plan #3 changes both ends of the call atomically. No external consumers.
|
||||
+7
-2
@@ -6,19 +6,22 @@ package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer) {
|
||||
h := &handlers{pool: pool, logger: logger, events: events}
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
@@ -54,4 +57,6 @@ type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
@@ -49,7 +50,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||
return &handlers{pool: pool, logger: logger, events: w}, pool
|
||||
recCfg := config.RecommendationConfig{
|
||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||
}
|
||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
@@ -440,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1})
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
+59
-9
@@ -3,11 +3,15 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// RadioResponse is the body of GET /api/radio.
|
||||
@@ -15,24 +19,40 @@ type RadioResponse struct {
|
||||
Tracks []TrackRef `json:"tracks"`
|
||||
}
|
||||
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>.
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
|
||||
//
|
||||
// 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.
|
||||
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
|
||||
// picks from the user's library, scored by recommendation.Score.
|
||||
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
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)
|
||||
seedID, ok := parseUUID(raw)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id")
|
||||
return
|
||||
}
|
||||
limit := h.recCfg.RadioSize
|
||||
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
|
||||
}
|
||||
if limit > h.recCfg.RadioSizeMax {
|
||||
limit = h.recCfg.RadioSizeMax
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
track, err := q.GetTrackByID(r.Context(), id)
|
||||
track, err := q.GetTrackByID(r.Context(), seedID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "seed_track not found")
|
||||
@@ -54,7 +74,37 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RadioResponse{
|
||||
Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)},
|
||||
})
|
||||
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: load candidates", "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,
|
||||
}
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||
|
||||
out := make([]TrackRef, 0, len(picks)+1)
|
||||
out = append(out, trackRefFrom(track, album.Title, artist.Name))
|
||||
for _, p := range picks {
|
||||
al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: resolve album", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed")
|
||||
return
|
||||
}
|
||||
ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: radio: resolve artist", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed")
|
||||
return
|
||||
}
|
||||
out = append(out, trackRefFrom(p.Track, al.Title, ar.Name))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RadioResponse{Tracks: out})
|
||||
}
|
||||
|
||||
+95
-55
@@ -1,90 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRadio(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) {
|
||||
func TestHandleRadio_ColdStart_OnlySeedReturned(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)
|
||||
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 body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Tracks []TrackRef `json:"tracks"`
|
||||
var resp RadioResponse
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.Tracks) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(resp.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])
|
||||
if resp.Tracks[0].Title != "Seed" {
|
||||
t.Errorf("seed not first: %v", resp.Tracks[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRadio_NotFound(t *testing.T) {
|
||||
func TestHandleRadio_Typical_SeedFirstPlusPicks(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)
|
||||
for i := 2; i <= 6; i++ {
|
||||
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
|
||||
}
|
||||
|
||||
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 {
|
||||
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
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)
|
||||
var resp RadioResponse
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.Tracks) != 6 {
|
||||
t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks))
|
||||
}
|
||||
if resp.Tracks[0].Title != "Seed" {
|
||||
t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
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_UnknownSeedIs404(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d", 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)
|
||||
func TestHandleRadio_BadSeedIs400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
w := callRadio(h, user, "seed_track=not-a-uuid")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status=%d, want 400", w.Code)
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRadio_MissingSeedIs400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "x", false)
|
||||
w := callRadio(h, user, "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRadio_BadLimitIs400(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)
|
||||
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("limit=0 status = %d", w.Code)
|
||||
}
|
||||
w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("limit=-1 status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRadio_LimitClampedToMax(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)
|
||||
for i := 2; i <= 6; i++ {
|
||||
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
|
||||
}
|
||||
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var resp RadioResponse
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// We only have 6 tracks; clamped limit (max 200) returns all 6.
|
||||
if len(resp.Tracks) != 6 {
|
||||
t.Errorf("len = %d, want 6", len(resp.Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Library LibraryConfig `yaml:"library"`
|
||||
Subsonic SubsonicConfig `yaml:"subsonic"`
|
||||
Events EventsConfig `yaml:"events"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Library LibraryConfig `yaml:"library"`
|
||||
Subsonic SubsonicConfig `yaml:"subsonic"`
|
||||
Events EventsConfig `yaml:"events"`
|
||||
Recommendation RecommendationConfig `yaml:"recommendation"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -63,6 +64,19 @@ type EventsConfig struct {
|
||||
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
||||
}
|
||||
|
||||
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
|
||||
// All weights are operator-tunable; defaults match the spec recommendations.
|
||||
type RecommendationConfig struct {
|
||||
BaseWeight float64 `yaml:"base_weight"`
|
||||
LikeBoost float64 `yaml:"like_boost"`
|
||||
RecencyWeight float64 `yaml:"recency_weight"`
|
||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||
RadioSize int `yaml:"radio_size"`
|
||||
RadioSizeMax int `yaml:"radio_size_max"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{Address: ":4533"},
|
||||
@@ -73,6 +87,16 @@ func Default() Config {
|
||||
SkipMaxCompletionRatio: 0.5,
|
||||
SkipMaxDurationPlayedMs: 30000,
|
||||
},
|
||||
Recommendation: RecommendationConfig{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
RecentlyPlayedHours: 1,
|
||||
RadioSize: 50,
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: recommendation.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||
SELECT
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
FROM tracks t
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
max(started_at) AS last_played_at,
|
||||
count(*) AS play_count,
|
||||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
) pe ON true
|
||||
WHERE t.id <> $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
AND started_at > now() - $3 * interval '1 hour'
|
||||
)
|
||||
`
|
||||
|
||||
type LoadRadioCandidatesParams struct {
|
||||
UserID pgtype.UUID
|
||||
ID pgtype.UUID
|
||||
Column3 interface{}
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesRow struct {
|
||||
Track Track
|
||||
IsLiked bool
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
}
|
||||
|
||||
// Returns all tracks except the seed and any played by the user within
|
||||
// the last $3 hours, joined with the stats needed for scoring:
|
||||
//
|
||||
// is_liked — boolean from general_likes for this user
|
||||
// last_played_at — max(play_events.started_at) for this user/track
|
||||
// play_count — count of play_events for this user/track
|
||||
// skip_count — play_events where was_skipped=true
|
||||
func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidatesParams) ([]LoadRadioCandidatesRow, error) {
|
||||
rows, err := q.db.Query(ctx, loadRadioCandidates, arg.UserID, arg.ID, arg.Column3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LoadRadioCandidatesRow
|
||||
for rows.Next() {
|
||||
var i LoadRadioCandidatesRow
|
||||
if err := rows.Scan(
|
||||
&i.Track.ID,
|
||||
&i.Track.Title,
|
||||
&i.Track.AlbumID,
|
||||
&i.Track.ArtistID,
|
||||
&i.Track.TrackNumber,
|
||||
&i.Track.DiscNumber,
|
||||
&i.Track.DurationMs,
|
||||
&i.Track.FilePath,
|
||||
&i.Track.FileSize,
|
||||
&i.Track.FileFormat,
|
||||
&i.Track.Bitrate,
|
||||
&i.Track.Mbid,
|
||||
&i.Track.Genre,
|
||||
&i.Track.AddedAt,
|
||||
&i.Track.UpdatedAt,
|
||||
&i.IsLiked,
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- name: LoadRadioCandidates :many
|
||||
-- Returns all tracks except the seed and any played by the user within
|
||||
-- the last $3 hours, joined with the stats needed for scoring:
|
||||
-- is_liked — boolean from general_likes for this user
|
||||
-- last_played_at — max(play_events.started_at) for this user/track
|
||||
-- play_count — count of play_events for this user/track
|
||||
-- skip_count — play_events where was_skipped=true
|
||||
SELECT
|
||||
sqlc.embed(t),
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
FROM tracks t
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
max(started_at) AS last_played_at,
|
||||
count(*) AS play_count,
|
||||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
) pe ON true
|
||||
WHERE t.id <> $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
AND started_at > now() - $3 * interval '1 hour'
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func LoadCandidates(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, seedID pgtype.UUID,
|
||||
recentlyPlayedHours int,
|
||||
) ([]Candidate, error) {
|
||||
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
||||
UserID: userID,
|
||||
ID: seedID,
|
||||
Column3: float64(recentlyPlayedHours),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
if r.LastPlayedAt.Valid {
|
||||
t := r.LastPlayedAt.Time
|
||||
lpt = &t
|
||||
}
|
||||
out = append(out, Candidate{
|
||||
Track: r.Track,
|
||||
Inputs: ScoringInputs{
|
||||
IsGeneralLiked: r.IsLiked,
|
||||
LastPlayedAt: lpt,
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
pool *pgxpool.Pool
|
||||
q *dbq.Queries
|
||||
user pgtype.UUID
|
||||
tracks []dbq.Track // tracks[0] is the canonical seed
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, n int) fixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
q := dbq.New(pool)
|
||||
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
|
||||
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID})
|
||||
tracks := make([]dbq.Track, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: string(rune('A' + i)),
|
||||
AlbumID: al.ID,
|
||||
ArtistID: a.ID,
|
||||
FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac",
|
||||
DurationMs: 120_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("track[%d]: %v", i, err)
|
||||
}
|
||||
tracks = append(tracks, tr)
|
||||
}
|
||||
return fixture{pool: pool, q: q, user: u.ID, tracks: tracks}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
||||
f := newFixture(t, 5)
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len = %d, want 4 (5 minus seed)", len(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == f.tracks[0].ID {
|
||||
t.Errorf("seed appeared in candidate set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
|
||||
f := newFixture(t, 3)
|
||||
// Seed = tracks[0]. Other tracks are tracks[1], tracks[2].
|
||||
// Insert a play_session and a play_event for tracks[1] 30 minutes ago.
|
||||
now := time.Now().UTC()
|
||||
thirtyMinAgo := now.Add(-30 * time.Minute)
|
||||
sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
|
||||
UserID: f.user,
|
||||
StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
|
||||
ClientID: nil,
|
||||
})
|
||||
_, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user,
|
||||
TrackID: f.tracks[1].ID,
|
||||
SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
|
||||
ClientID: nil,
|
||||
})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
// Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played).
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got))
|
||||
}
|
||||
if got[0].Track.ID != f.tracks[2].ID {
|
||||
t.Errorf("expected tracks[2], got %v", got[0].Track.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_StatJoin(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
// Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago.
|
||||
if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil {
|
||||
t.Fatalf("like: %v", err)
|
||||
}
|
||||
twoH := time.Now().UTC().Add(-2 * time.Hour)
|
||||
sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
|
||||
UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
|
||||
})
|
||||
// First play: completed (was_skipped=false).
|
||||
pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
|
||||
})
|
||||
dur := int32(120_000)
|
||||
ratio := 1.0
|
||||
_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
|
||||
ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true},
|
||||
DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false,
|
||||
})
|
||||
// Second play: skipped (was_skipped=true).
|
||||
pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
|
||||
UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
|
||||
StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true},
|
||||
})
|
||||
dur2 := int32(5_000)
|
||||
ratio2 := 0.04
|
||||
_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
|
||||
ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true},
|
||||
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
|
||||
})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
if !c.Inputs.IsGeneralLiked {
|
||||
t.Errorf("IsGeneralLiked = false, want true")
|
||||
}
|
||||
if c.Inputs.PlayCount != 2 {
|
||||
t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount)
|
||||
}
|
||||
if c.Inputs.SkipCount != 1 {
|
||||
t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount)
|
||||
}
|
||||
if c.Inputs.LastPlayedAt == nil {
|
||||
t.Errorf("LastPlayedAt = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Inputs.LastPlayedAt != nil {
|
||||
t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt)
|
||||
}
|
||||
if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 {
|
||||
t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||||
})
|
||||
// Alice likes tracks[1]; Bob shouldn't see it.
|
||||
_ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
|
||||
|
||||
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCandidates: %v", err)
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked {
|
||||
t.Errorf("bob sees alice's like on track %v", c.Track.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trackTitles(cs []Candidate) []string {
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
out = append(out, c.Track.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package recommendation implements the weighted-shuffle scoring engine
|
||||
// from spec §6. The Score function is pure and takes an injectable RNG so
|
||||
// tests can pin jitter to deterministic values.
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScoringInputs are the per-track facts the score function consumes.
|
||||
// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore.
|
||||
type ScoringInputs struct {
|
||||
IsGeneralLiked bool
|
||||
LastPlayedAt *time.Time // nil = never played
|
||||
PlayCount int // total play_events
|
||||
SkipCount int // play_events with was_skipped=true
|
||||
}
|
||||
|
||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||
// config.RecommendationConfig and are propagated here per request.
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
//
|
||||
// score = base
|
||||
// + (is_general_liked ? LikeBoost : 0)
|
||||
// + recency_decay * RecencyWeight
|
||||
// - skip_ratio * SkipPenalty
|
||||
// + small_random_jitter
|
||||
//
|
||||
// Higher score = more likely to surface. rng is a function returning a
|
||||
// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed
|
||||
// value in tests.
|
||||
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
|
||||
s := w.BaseWeight
|
||||
if in.IsGeneralLiked {
|
||||
s += w.LikeBoost
|
||||
}
|
||||
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
|
||||
// recencyDecay returns a value in [0, 1]:
|
||||
// - never played → 1.0 (cold-start tracks compete favorably with stale ones).
|
||||
// - age < 30 days → linear ramp age_days / 30.
|
||||
// - age ≥ 30 days → 1.0 (capped).
|
||||
//
|
||||
// Negative ages (clock skew) clamp to 0 to avoid math weirdness.
|
||||
func recencyDecay(lastPlayed *time.Time, now time.Time) float64 {
|
||||
if lastPlayed == nil {
|
||||
return 1.0
|
||||
}
|
||||
age := now.Sub(*lastPlayed)
|
||||
days := age.Hours() / 24
|
||||
if days < 0 {
|
||||
return 0.0
|
||||
}
|
||||
if days >= 30 {
|
||||
return 1.0
|
||||
}
|
||||
return days / 30.0
|
||||
}
|
||||
|
||||
// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0
|
||||
// rather than dividing by zero, so they aren't penalized.
|
||||
func skipRatio(plays, skips int) float64 {
|
||||
if plays == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(skips) / float64(plays)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func defaultWeights() ScoringWeights {
|
||||
return ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
}
|
||||
}
|
||||
|
||||
func fixedRNG(v float64) func() float64 {
|
||||
return func() float64 { return v }
|
||||
}
|
||||
|
||||
func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) {
|
||||
in := ScoringInputs{}
|
||||
now := time.Now().UTC()
|
||||
got := Score(in, defaultWeights(), now, fixedRNG(0.5))
|
||||
// rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0
|
||||
// expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0
|
||||
want := 2.0
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("score = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LikeBoost(t *testing.T) {
|
||||
notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
|
||||
liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5))
|
||||
if math.Abs(liked-notLiked-2.0) > 1e-9 {
|
||||
t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_RecencyRamp_15Days(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
played := now.Add(-15 * 24 * time.Hour)
|
||||
got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5))
|
||||
// recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5
|
||||
want := 1.5
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("score = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
played := now.Add(-60 * 24 * time.Hour)
|
||||
got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5))
|
||||
want := 2.0 // base + capped recency (1.0)
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("score = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_SkipRatio(t *testing.T) {
|
||||
// 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5
|
||||
in := ScoringInputs{PlayCount: 4, SkipCount: 2}
|
||||
got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5))
|
||||
// recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0;
|
||||
// for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above
|
||||
// explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5.
|
||||
want := 1.5
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("score = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) {
|
||||
in := ScoringInputs{PlayCount: 0, SkipCount: 0}
|
||||
got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5))
|
||||
want := 2.0 // base + recency, no skip penalty
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("score = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_JitterBounds(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(42))
|
||||
w := defaultWeights()
|
||||
now := time.Now().UTC()
|
||||
mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
got := Score(ScoringInputs{}, w, now, r.Float64)
|
||||
if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 {
|
||||
t.Fatalf("score %v outside jitter band [%v, %v]",
|
||||
got, mid-w.JitterMagnitude, mid+w.JitterMagnitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_Determinism(t *testing.T) {
|
||||
in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3}
|
||||
w := defaultWeights()
|
||||
now := time.Now().UTC()
|
||||
a := Score(in, w, now, fixedRNG(0.7))
|
||||
b := Score(in, w, now, fixedRNG(0.7))
|
||||
if a != b {
|
||||
t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecencyDecay_NilIsOne(t *testing.T) {
|
||||
if got := recencyDecay(nil, time.Now()); got != 1.0 {
|
||||
t.Errorf("recencyDecay(nil) = %v, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecencyDecay_Clamping(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cases := []struct {
|
||||
ageDays float64
|
||||
want float64
|
||||
}{
|
||||
{0, 0.0},
|
||||
{15, 0.5},
|
||||
{30, 1.0},
|
||||
{45, 1.0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run("", func(t *testing.T) {
|
||||
past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour)))
|
||||
got := recencyDecay(&past, now)
|
||||
if math.Abs(got-c.want) > 1e-9 {
|
||||
t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) {
|
||||
if got := skipRatio(0, 0); got != 0.0 {
|
||||
t.Errorf("skipRatio(0,0) = %v, want 0.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipRatio_Half(t *testing.T) {
|
||||
if got := skipRatio(4, 2); got != 0.5 {
|
||||
t.Errorf("skipRatio(4,2) = %v, want 0.5", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Candidate pairs a track with the inputs needed to score it.
|
||||
type Candidate struct {
|
||||
Track dbq.Track
|
||||
Inputs ScoringInputs
|
||||
}
|
||||
|
||||
// Shuffle scores each candidate, sorts descending by score, and returns
|
||||
// the top `limit` candidates. limit <= 0 returns nil; nil input returns
|
||||
// nil. Pure — no IO, no global state beyond the rng callback.
|
||||
func Shuffle(
|
||||
candidates []Candidate,
|
||||
weights ScoringWeights,
|
||||
now time.Time,
|
||||
rng func() float64,
|
||||
limit int,
|
||||
) []Candidate {
|
||||
if len(candidates) == 0 || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
scored := make([]struct {
|
||||
c Candidate
|
||||
score float64
|
||||
}, len(candidates))
|
||||
for i, c := range candidates {
|
||||
scored[i].c = c
|
||||
scored[i].score = Score(c.Inputs, weights, now, rng)
|
||||
}
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
if limit > len(scored) {
|
||||
limit = len(scored)
|
||||
}
|
||||
out := make([]Candidate, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
out[i] = scored[i].c
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func cand(id string, in ScoringInputs) Candidate {
|
||||
t := dbq.Track{Title: id}
|
||||
_ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded
|
||||
return Candidate{Track: t, Inputs: in}
|
||||
}
|
||||
|
||||
func TestShuffle_LikedRanksAboveUnliked(t *testing.T) {
|
||||
cs := []Candidate{
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Errorf("liked track did not rank first: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_HighSkipRanksLast(t *testing.T) {
|
||||
cs := []Candidate{
|
||||
cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0
|
||||
cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0
|
||||
cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" {
|
||||
t.Errorf("skip-ratio ordering broken: %v", titles(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_LimitTruncates(t *testing.T) {
|
||||
cs := make([]Candidate, 100)
|
||||
for i := range cs {
|
||||
cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{})
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
if len(out) != 10 {
|
||||
t.Errorf("len = %d, want 10", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
|
||||
// Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked.
|
||||
r := rand.New(rand.NewSource(42))
|
||||
for i := 0; i < 500; i++ {
|
||||
cs := []Candidate{
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10)
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_Empty_ReturnsEmpty(t *testing.T) {
|
||||
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func titles(cs []Candidate) []string {
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
out = append(out, c.Track.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pgtype-uuid placeholder: Candidate doesn't need a real UUID for these
|
||||
// pure tests; the Track.Title field is the human-readable handle. Track ID
|
||||
// is left as zero pgtype.UUID and never compared.
|
||||
var _ pgtype.UUID
|
||||
@@ -29,15 +29,16 @@ type ScanTrigger interface {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Logger *slog.Logger
|
||||
Pool *pgxpool.Pool
|
||||
Scanner ScanTrigger
|
||||
SubsonicCfg subsonic.Config
|
||||
EventsCfg config.EventsConfig
|
||||
Logger *slog.Logger
|
||||
Pool *pgxpool.Pool
|
||||
Scanner ScanTrigger
|
||||
SubsonicCfg subsonic.Config
|
||||
EventsCfg config.EventsConfig
|
||||
RecommendationCfg config.RecommendationConfig
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg}
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -54,7 +55,7 @@ func (s *Server) Router() http.Handler {
|
||||
s.EventsCfg.SkipMaxCompletionRatio,
|
||||
s.EventsCfg.SkipMaxDurationPlayedMs,
|
||||
)
|
||||
api.Mount(r, s.Pool, s.Logger, writer)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg)
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin(s.Pool))
|
||||
if s.Scanner != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user