diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go new file mode 100644 index 00000000..8b825456 --- /dev/null +++ b/internal/recommendation/candidates.go @@ -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 +} diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go new file mode 100644 index 00000000..f8522d23 --- /dev/null +++ b/internal/recommendation/candidates_test.go @@ -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 +}