diff --git a/internal/api/home.go b/internal/api/home.go index f0f14640..0e2daec5 100644 --- a/internal/api/home.go +++ b/internal/api/home.go @@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), + YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)), + YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)), } for _, row := range data.RecentlyAddedAlbums { out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) @@ -46,6 +48,12 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { for _, row := range data.LastPlayedArtists { out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) } + for _, row := range data.YouMightLikeAlbums { + out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.YouMightLikeArtists { + out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } writeJSON(w, http.StatusOK, out) } @@ -78,6 +86,8 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)), MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)), LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)), + YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)), + YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)), } for _, row := range data.RecentlyAddedAlbums { out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID)) @@ -94,6 +104,12 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { for _, row := range data.LastPlayedArtists { out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID)) } + for _, row := range data.YouMightLikeAlbums { + out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.YouMightLikeArtists { + out.YouMightLikeArtists = append(out.YouMightLikeArtists, uuidToString(row.Artist.ID)) + } writeJSON(w, http.StatusOK, out) } diff --git a/internal/api/types.go b/internal/api/types.go index d9fc0c06..bc9f98d1 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -116,6 +116,11 @@ type HomePayload struct { RediscoverArtists []ArtistRef `json:"rediscover_artists"` MostPlayedTracks []TrackRef `json:"most_played_tracks"` LastPlayedArtists []ArtistRef `json:"last_played_artists"` + // You-might-like: in-library albums/artists predicted from the user's + // listening that they don't actively spin. Built daily; gated on a + // minimum listening history so a thin profile gets empty rows. + YouMightLikeAlbums []AlbumRef `json:"you_might_like_albums"` + YouMightLikeArtists []ArtistRef `json:"you_might_like_artists"` } // HomeIndexPayload is the response body of GET /api/home/index — the @@ -134,6 +139,8 @@ type HomeIndexPayload struct { RediscoverArtists []string `json:"rediscover_artists"` MostPlayedTracks []string `json:"most_played_tracks"` LastPlayedArtists []string `json:"last_played_artists"` + YouMightLikeAlbums []string `json:"you_might_like_albums"` + YouMightLikeArtists []string `json:"you_might_like_artists"` } // HistoryEvent is one play in a user's listening history. Used by diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 3556ce8e..641f6b90 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -549,3 +549,17 @@ type UserInvite struct { RedeemedAt pgtype.Timestamptz RedeemedBy pgtype.UUID } + +type YouMightLikeAlbum struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + Rank int32 + BuiltAt pgtype.Timestamptz +} + +type YouMightLikeArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Rank int32 + BuiltAt pgtype.Timestamptz +} diff --git a/internal/db/dbq/you_might_like.sql.go b/internal/db/dbq/you_might_like.sql.go new file mode 100644 index 00000000..4f2a98c0 --- /dev/null +++ b/internal/db/dbq/you_might_like.sql.go @@ -0,0 +1,217 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: you_might_like.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countListeningSignalForUser = `-- name: CountListeningSignalForUser :one + +SELECT + count(DISTINCT pe.track_id)::bigint AS distinct_tracks, + count(DISTINCT t.artist_id)::bigint AS distinct_artists +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 AND pe.was_skipped = false +` + +type CountListeningSignalForUserRow struct { + DistinctTracks int64 + DistinctArtists int64 +} + +// "You might like" Home rows (#790). The daily build computes ranked +// album/artist IDs in Go (similarity roll-up + cold-start gate) and +// atomic-replaces them here; /api/home reads them back hydrated. +// Cold-start gate. Returns the breadth of the user's real listening: +// distinct unskipped tracks played and distinct artists played. The +// build skips You-might-like entirely below a threshold (a thin history +// yields near-random roll-ups). was_skipped=false so a user spamming +// next can't inflate the signal. +func (q *Queries) CountListeningSignalForUser(ctx context.Context, userID pgtype.UUID) (CountListeningSignalForUserRow, error) { + row := q.db.QueryRow(ctx, countListeningSignalForUser, userID) + var i CountListeningSignalForUserRow + err := row.Scan(&i.DistinctTracks, &i.DistinctArtists) + return i, err +} + +const deleteYouMightLikeAlbumsForUser = `-- name: DeleteYouMightLikeAlbumsForUser :exec +DELETE FROM you_might_like_albums WHERE user_id = $1 +` + +func (q *Queries) DeleteYouMightLikeAlbumsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteYouMightLikeAlbumsForUser, userID) + return err +} + +const deleteYouMightLikeArtistsForUser = `-- name: DeleteYouMightLikeArtistsForUser :exec +DELETE FROM you_might_like_artists WHERE user_id = $1 +` + +func (q *Queries) DeleteYouMightLikeArtistsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteYouMightLikeArtistsForUser, userID) + return err +} + +const insertYouMightLikeAlbum = `-- name: InsertYouMightLikeAlbum :exec +INSERT INTO you_might_like_albums (user_id, album_id, rank) +VALUES ($1, $2, $3) +` + +type InsertYouMightLikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + Rank int32 +} + +func (q *Queries) InsertYouMightLikeAlbum(ctx context.Context, arg InsertYouMightLikeAlbumParams) error { + _, err := q.db.Exec(ctx, insertYouMightLikeAlbum, arg.UserID, arg.AlbumID, arg.Rank) + return err +} + +const insertYouMightLikeArtist = `-- name: InsertYouMightLikeArtist :exec +INSERT INTO you_might_like_artists (user_id, artist_id, rank) +VALUES ($1, $2, $3) +` + +type InsertYouMightLikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Rank int32 +} + +func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMightLikeArtistParams) error { + _, err := q.db.Exec(ctx, insertYouMightLikeArtist, arg.UserID, arg.ArtistID, arg.Rank) + return err +} + +const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM you_might_like_albums yml +JOIN albums ON albums.id = yml.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2 +` + +type ListYouMightLikeAlbumsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeAlbumsForUserRow struct { + Album Album + ArtistName string +} + +// Read path. Same projection as ListRediscoverAlbumsForUser so the API +// layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by +// the rank persisted at build time. Final cross-section dedup (vs Most +// Played / Rediscover) and the output cap land in the Go layer +// (internal/recommendation/home.go). +func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYouMightLikeAlbumsForUserParams) ([]ListYouMightLikeAlbumsForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeAlbumsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeAlbumsForUserRow + for rows.Next() { + var i ListYouMightLikeAlbumsForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM you_might_like_artists yml +JOIN artists ON artists.id = yml.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2 +` + +type ListYouMightLikeArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// Read path. Same projection as ListRediscoverArtistsForUser (embeds the +// artist + a representative cover_album_id + album_count) so the API +// layer reuses artistRefFromCovered. Ordered by persisted rank. +func (q *Queries) ListYouMightLikeArtistsForUser(ctx context.Context, arg ListYouMightLikeArtistsForUserParams) ([]ListYouMightLikeArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeArtistsForUserRow + for rows.Next() { + var i ListYouMightLikeArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.Artist.ArtistThumbPath, + &i.Artist.ArtistFanartPath, + &i.Artist.ArtistArtSource, + &i.Artist.ArtistArtSourcesVersion, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/0034_you_might_like.down.sql b/internal/db/migrations/0034_you_might_like.down.sql new file mode 100644 index 00000000..e31679d6 --- /dev/null +++ b/internal/db/migrations/0034_you_might_like.down.sql @@ -0,0 +1,3 @@ +-- Reverse 0034_you_might_like.up.sql. +DROP TABLE IF EXISTS you_might_like_artists; +DROP TABLE IF EXISTS you_might_like_albums; diff --git a/internal/db/migrations/0034_you_might_like.up.sql b/internal/db/migrations/0034_you_might_like.up.sql new file mode 100644 index 00000000..dc9cf162 --- /dev/null +++ b/internal/db/migrations/0034_you_might_like.up.sql @@ -0,0 +1,36 @@ +-- 0034_you_might_like.up.sql — "You might like" Home rows (#790). +-- +-- Per-user ranked lists of IN-LIBRARY albums/artists the listener +-- doesn't actively spin but is predicted to enjoy, derived from the +-- same similarity + like-weighted candidate engine that powers For-You +-- (rolled up from track scores to album/artist). Built in the daily +-- 3am BuildSystemPlaylists pass and atomic-replaced, exactly like the +-- system playlists; read back by /api/home. +-- +-- Two thin tables (id + rank) rather than denormalized snapshots: the +-- Home read path hydrates album/artist refs through the existing +-- albums/artists joins, so a later metadata edit is reflected without +-- a rebuild. CASCADE on user/album/artist deletes keeps them clean. + +CREATE TABLE you_might_like_albums ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + rank integer NOT NULL, + built_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, album_id) +); + +-- Read path orders by rank within a user; the index serves it directly. +CREATE INDEX you_might_like_albums_user_rank_idx + ON you_might_like_albums (user_id, rank); + +CREATE TABLE you_might_like_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + rank integer NOT NULL, + built_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); + +CREATE INDEX you_might_like_artists_user_rank_idx + ON you_might_like_artists (user_id, rank); diff --git a/internal/db/queries/you_might_like.sql b/internal/db/queries/you_might_like.sql new file mode 100644 index 00000000..c6038d5a --- /dev/null +++ b/internal/db/queries/you_might_like.sql @@ -0,0 +1,66 @@ +-- "You might like" Home rows (#790). The daily build computes ranked +-- album/artist IDs in Go (similarity roll-up + cold-start gate) and +-- atomic-replaces them here; /api/home reads them back hydrated. + +-- name: CountListeningSignalForUser :one +-- Cold-start gate. Returns the breadth of the user's real listening: +-- distinct unskipped tracks played and distinct artists played. The +-- build skips You-might-like entirely below a threshold (a thin history +-- yields near-random roll-ups). was_skipped=false so a user spamming +-- next can't inflate the signal. +SELECT + count(DISTINCT pe.track_id)::bigint AS distinct_tracks, + count(DISTINCT t.artist_id)::bigint AS distinct_artists +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 AND pe.was_skipped = false; + +-- name: DeleteYouMightLikeAlbumsForUser :exec +DELETE FROM you_might_like_albums WHERE user_id = $1; + +-- name: InsertYouMightLikeAlbum :exec +INSERT INTO you_might_like_albums (user_id, album_id, rank) +VALUES ($1, $2, $3); + +-- name: DeleteYouMightLikeArtistsForUser :exec +DELETE FROM you_might_like_artists WHERE user_id = $1; + +-- name: InsertYouMightLikeArtist :exec +INSERT INTO you_might_like_artists (user_id, artist_id, rank) +VALUES ($1, $2, $3); + +-- name: ListYouMightLikeAlbumsForUser :many +-- Read path. Same projection as ListRediscoverAlbumsForUser so the API +-- layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by +-- the rank persisted at build time. Final cross-section dedup (vs Most +-- Played / Rediscover) and the output cap land in the Go layer +-- (internal/recommendation/home.go). +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM you_might_like_albums yml +JOIN albums ON albums.id = yml.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2; + +-- name: ListYouMightLikeArtistsForUser :many +-- Read path. Same projection as ListRediscoverArtistsForUser (embeds the +-- artist + a representative cover_album_id + album_count) so the API +-- layer reuses artistRefFromCovered. Ordered by persisted rank. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM you_might_like_artists yml +JOIN artists ON artists.id = yml.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2; diff --git a/internal/playlists/system.go b/internal/playlists/system.go index b2eeead5..8a2915f4 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -497,6 +497,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. built = append(built, out...) } + // "You might like" Home rows reuse the same similarity engine but + // persist to dedicated album/artist tables (not playlist_tracks). + // Computed here (reads only) and atomic-replaced inside the tx below, + // alongside the system playlists. A failed/gated computation is + // handled by yml.built (leave prior rows vs. clear) — never fatal. + yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now) + // Atomic replace inside a transaction. tx, err := pool.Begin(ctx) if err != nil { @@ -530,6 +537,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. createdIDs = append(createdIDs, id) } + if yml.built { + if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil { + buildErr = fmt.Errorf("persist you-might-like: %w", err) + return buildErr + } + } + if err := tx.Commit(ctx); err != nil { buildErr = fmt.Errorf("commit tx: %w", err) return buildErr diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go new file mode 100644 index 00000000..e2d8b47e --- /dev/null +++ b/internal/playlists/you_might_like.go @@ -0,0 +1,227 @@ +// you_might_like.go builds the per-user "You might like" Home rows +// (#790): in-library albums/artists the listener doesn't actively spin +// but is predicted to enjoy. It reuses the For-You candidate engine +// (similarity + like-weighted scoring) and rolls the per-track scores up +// to album/artist granularity, gated on a minimum listening history so a +// near-empty profile ships nothing rather than noise. +// +// Built in the same daily BuildSystemPlaylists pass and atomic-replaced +// alongside the system playlists; read back by /api/home. +package playlists + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "sort" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +const ( + // Cold-start gate. Below this much *real* listening the similarity + // roll-up degrades toward random fill, so You-might-like ships + // nothing rather than arbitrary tiles. Both bars must clear: distinct + // tracks rules out "played 3 songs on repeat," distinct artists rules + // out "hammered one album." Conservative defaults; raise if early + // rows feel random on thin libraries. + youMightLikeMinDistinctTracks = 20 + youMightLikeMinDistinctArtists = 5 + + // Persisted depth — a few more than the rendered HomeYouMightLikeLimit + // so the read-time cross-section dedup (vs Most Played / Rediscover) + // has headroom before trimming. + youMightLikeAlbumsN = 12 + youMightLikeArtistsN = 12 + + // One strongly-matched artist shouldn't fill the albums row. + youMightLikeMaxAlbumsPerArtist = 2 + + // Aggregation: sum of an entity's top-K track scores. Favors albums/ + // artists with several good matches over a single outlier track. + youMightLikeAggTopK = 3 +) + +// youMightLikeResult carries one day's roll-up. built=false means the +// computation failed and the caller must leave any existing rows intact; +// built=true with nil slices means "explicitly empty" (cold-start gate or +// no candidates) and the caller should clear the user's rows. +type youMightLikeResult struct { + albumIDs []pgtype.UUID + artistIDs []pgtype.UUID + built bool +} + +// buildYouMightLike computes the user's daily album/artist rolls. Reads +// only — the caller persists inside the build tx. Never returns an error: +// a failure logs and yields built=false so the prior day's rows survive. +func buildYouMightLike( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, dateStr string, now time.Time, +) youMightLikeResult { + signal, err := q.CountListeningSignalForUser(ctx, userID) + if err != nil { + logger.Warn("you-might-like: listening-signal query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return youMightLikeResult{built: false} + } + if signal.DistinctTracks < youMightLikeMinDistinctTracks || + signal.DistinctArtists < youMightLikeMinDistinctArtists { + // Cold start: not enough listening to recommend from. built=true + // clears any stale rows (normally none) and ships an empty row. + return youMightLikeResult{built: true} + } + + seeds, err := q.PickTopPlayedTracksForUser(ctx, userID) + if err != nil { + logger.Warn("you-might-like: seed query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return youMightLikeResult{built: false} + } + seed := pickForYouSeedForDay(seeds, userID, dateStr) + if !seed.Valid { + return youMightLikeResult{built: true} + } + + zeroVec := recommendation.SessionVector{Seed: true} + cands, err := recommendation.LoadCandidatesFromSimilarity( + ctx, q, userID, seed, 1, zeroVec, + []pgtype.UUID{seed}, systemForYouSourceLimits(), + ) + if err != nil { + logger.Warn("you-might-like: candidate load failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return youMightLikeResult{built: false} + } + + albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now) + return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true} +} + +// scoredEntity is an album or artist with its aggregated score. +type scoredEntity struct { + id pgtype.UUID + score float64 +} + +// rollUpCandidates scores every candidate track (systemMixWeights, jitter +// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the +// scores up to album and artist via sum-of-top-K. Returns the top-N album +// and artist IDs, with a per-artist cap on the album list. +func rollUpCandidates( + cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, +) (albumIDs, artistIDs []pgtype.UUID) { + rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) + albumScores := map[pgtype.UUID][]float64{} + artistScores := map[pgtype.UUID][]float64{} + albumArtist := map[pgtype.UUID]pgtype.UUID{} + for _, c := range cands { + s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64) + if c.Track.AlbumID.Valid { + albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s) + albumArtist[c.Track.AlbumID] = c.Track.ArtistID + } + if c.Track.ArtistID.Valid { + artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s) + } + } + + rankedAlbums := capAlbumsPerArtist( + rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist) + albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN) + artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN) + return albumIDs, artistIDs +} + +// rankEntities aggregates each entity's track scores (sum-of-top-K) and +// returns them sorted by score DESC, ties broken deterministically by +// tieBreakHash(id, dateStr) so ordering is stable within a day. +func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity { + out := make([]scoredEntity, 0, len(scoresByEntity)) + for id, scores := range scoresByEntity { + out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)}) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].score != out[j].score { + return out[i].score > out[j].score + } + return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr) + }) + return out +} + +// sumTopK sorts scores DESC and sums the highest k. +func sumTopK(scores []float64, k int) float64 { + sort.Sort(sort.Reverse(sort.Float64Slice(scores))) + sum := 0.0 + for i := 0; i < len(scores) && i < k; i++ { + sum += scores[i] + } + return sum +} + +// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist, +// preserving input (score) order. +func capAlbumsPerArtist( + albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int, +) []scoredEntity { + perArtist := map[pgtype.UUID]int{} + out := make([]scoredEntity, 0, len(albums)) + for _, a := range albums { + art := albumArtist[a.id] + if art.Valid { + if perArtist[art] >= maxPerArtist { + continue + } + perArtist[art]++ + } + out = append(out, a) + } + return out +} + +// topNEntityIDs truncates to n and projects the IDs in rank order. +func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID { + if len(entities) > n { + entities = entities[:n] + } + out := make([]pgtype.UUID, 0, len(entities)) + for _, e := range entities { + out = append(out, e.id) + } + return out +} + +// persistYouMightLike atomic-replaces the user's You-might-like rows +// inside the build tx. Called only when the roll-up was freshly built; +// a failed computation leaves the prior rows untouched. +func persistYouMightLike( + ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult, +) error { + if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil { + return fmt.Errorf("delete you-might-like albums: %w", err) + } + for i, id := range r.albumIDs { + if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{ + UserID: userID, AlbumID: id, Rank: int32(i), + }); err != nil { + return fmt.Errorf("insert you-might-like album: %w", err) + } + } + if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil { + return fmt.Errorf("delete you-might-like artists: %w", err) + } + for i, id := range r.artistIDs { + if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{ + UserID: userID, ArtistID: id, Rank: int32(i), + }); err != nil { + return fmt.Errorf("insert you-might-like artist: %w", err) + } + } + return nil +} diff --git a/internal/playlists/you_might_like_db_test.go b/internal/playlists/you_might_like_db_test.go new file mode 100644 index 00000000..05e91df8 --- /dev/null +++ b/internal/playlists/you_might_like_db_test.go @@ -0,0 +1,99 @@ +package playlists_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +// countYouMightLike returns the persisted row counts for a user. +func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) { + t.Helper() + ctx := context.Background() + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID, + ).Scan(&albums); err != nil { + t.Fatalf("count albums: %v", err) + } + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID, + ).Scan(&artists); err != nil { + t.Fatalf("count artists: %v", err) + } + return albums, artists +} + +// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks = +// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no +// You-might-like rows — the build ships nothing rather than near-random +// tiles. +func TestYouMightLike_ColdStartGate(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3) + + if err := playlists.BuildSystemPlaylists( + context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build: %v", err) + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums != 0 || artists != 0 { + t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists", + albums, artists) + } +} + +// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists +// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the +// You-might-like tables in the daily build. +func TestYouMightLike_SufficientActivityPopulates(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4) + + if err := playlists.BuildSystemPlaylists( + context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build: %v", err) + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums == 0 { + t.Error("rich user should have you-might-like album rows; got 0") + } + if artists == 0 { + t.Error("rich user should have you-might-like artist rows; got 0") + } +} + +// TestYouMightLike_AtomicReplace: a second build replaces rather than +// appends, so counts stay bounded by the persisted depth. +func TestYouMightLike_AtomicReplace(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4) + ctx := context.Background() + + for i := 0; i < 2; i++ { + if err := playlists.BuildSystemPlaylists( + ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build %d: %v", i, err) + } + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums > 12 { + t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums) + } + if artists > 12 { + t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists) + } +} diff --git a/internal/playlists/you_might_like_test.go b/internal/playlists/you_might_like_test.go new file mode 100644 index 00000000..ffe390a4 --- /dev/null +++ b/internal/playlists/you_might_like_test.go @@ -0,0 +1,125 @@ +package playlists + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// fixedNow keeps recencyDecay stable across the pure roll-up tests. +var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) + +// byteOf returns the per-test entity discriminator (makeCand sets it in +// Bytes[15]) so assertions can name albums/artists by their small int. +func byteOf(u pgtype.UUID) byte { return u.Bytes[15] } + +// countWithByte reports how many of ids carry the given Bytes[15] marker. +func countWithByte(ids []pgtype.UUID, want byte) int { + n := 0 + for _, id := range ids { + if byteOf(id) == want { + n++ + } + } + return n +} + +func TestSumTopK(t *testing.T) { + if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 { + t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got) + } + if got := sumTopK([]float64{5}, 3); got != 5 { + t.Errorf("sumTopK fewer-than-k = %v, want 5", got) + } + if got := sumTopK(nil, 3); got != 0 { + t.Errorf("sumTopK empty = %v, want 0", got) + } +} + +func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) { + // Album 10 (artist 100): three solid matches. Album 20 (artist 200): + // one slightly-stronger single match. Sum-of-top-3 should rank the + // multi-match album ahead of the single-match one. Similarity gaps are + // wide enough that the ±0.1 jitter can't reorder the result. + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.8), + makeCand(2, 10, 100, 0.8), + makeCand(3, 10, 100, 0.8), + makeCand(4, 20, 200, 0.95), + } + albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + if len(albums) < 2 { + t.Fatalf("want >=2 albums, got %d", len(albums)) + } + if byteOf(albums[0]) != 10 { + t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0])) + } +} + +func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) { + // Artist 100 spans albums 10/11/12; the album row caps any one artist + // at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should + // still appear. + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.9), + makeCand(2, 11, 100, 0.85), + makeCand(3, 12, 100, 0.8), + makeCand(4, 20, 200, 0.7), + } + albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12) + if from100 > youMightLikeMaxAlbumsPerArtist { + t.Errorf("artist 100 contributed %d albums, want <= %d", + from100, youMightLikeMaxAlbumsPerArtist) + } + if countWithByte(albums, 20) != 1 { + t.Errorf("album 20 (artist 200) should survive the cap; got %d", + countWithByte(albums, 20)) + } +} + +func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) { + // Three artists; the artist roll-up should surface all three distinct + // artist IDs (no per-artist cap on the artist row). + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.9), + makeCand(2, 11, 101, 0.6), + makeCand(3, 12, 102, 0.3), + } + _, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + seen := map[byte]bool{} + for _, a := range artists { + seen[byteOf(a)] = true + } + for _, want := range []byte{100, 101, 102} { + if !seen[want] { + t.Errorf("artist %d missing from roll-up", want) + } + } +} + +func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) { + mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) { + var al, ar pgtype.UUID + al.Valid, ar.Valid = true, true + al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN) + return al, ar + } + a10, ar1 := mk(10, 1) + a11, _ := mk(11, 1) + a12, _ := mk(12, 1) + a20, ar2 := mk(20, 2) + albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2} + in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}} + got := capAlbumsPerArtist(in, albumArtist, 2) + if len(got) != 3 { + t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got)) + } + // Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped. + if byteOf(got[2].id) != 20 { + t.Errorf("third survivor = %d, want 20", byteOf(got[2].id)) + } +} diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index a731f833..7f380139 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -34,6 +34,13 @@ const ( // artist from dominating the row. Two is enough variety; higher // reads as "this artist's discography" instead of "rediscover". maxAlbumsPerArtistInRediscover = 2 + + // HomeYouMightLikeLimit is the rendered count for each You-might-like + // row. The build persists a few more (youMightLikeAlbumsN/ArtistsN) + // so the read-time cross-section dedup has headroom; this query asks + // for the persisted depth and the Go layer trims to this after dedup. + HomeYouMightLikeLimit = 10 + youMightLikeFetch = 12 ) // HomePayload is the composite returned by HomeData. All slices are @@ -45,6 +52,8 @@ type HomePayload struct { RediscoverArtists []dbq.ListRediscoverArtistsForUserRow MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow + YouMightLikeAlbums []dbq.ListYouMightLikeAlbumsForUserRow + YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow } // HomeData runs five queries in parallel and assembles the payload. @@ -77,9 +86,11 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, + YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{}, + YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{}, } - wg.Add(5) + wg.Add(7) go func() { defer wg.Done() rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) @@ -129,6 +140,28 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom } out.RediscoverArtists = rows }() + go func() { + defer wg.Done() + rows, err := q.ListYouMightLikeAlbumsForUser(ctx, dbq.ListYouMightLikeAlbumsForUserParams{ + UserID: userID, Limit: youMightLikeFetch, + }) + if err != nil { + fail("you_might_like_albums", err) + return + } + out.YouMightLikeAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListYouMightLikeArtistsForUser(ctx, dbq.ListYouMightLikeArtistsForUserParams{ + UserID: userID, Limit: youMightLikeFetch, + }) + if err != nil { + fail("you_might_like_artists", err) + return + } + out.YouMightLikeArtists = rows + }() wg.Wait() if firstErr != nil { @@ -140,9 +173,70 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom // the rediscover lists down toward HomeRediscoverLimit. out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks) out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks) + // You-might-like dedups against what the user already engages with + // (Most Played) and against the other Home rows so a tile doesn't + // appear twice. Trims to HomeYouMightLikeLimit after dedup. + out.YouMightLikeAlbums = applyYouMightLikeAlbumFilters( + out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums) + out.YouMightLikeArtists = applyYouMightLikeArtistFilters( + out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists) return out, nil } +// applyYouMightLikeAlbumFilters drops albums the user actively plays +// (Most Played) or that already appear in Rediscover, then trims to +// HomeYouMightLikeLimit. Build-time rank order is preserved. +func applyYouMightLikeAlbumFilters( + rows []dbq.ListYouMightLikeAlbumsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, + rediscover []dbq.ListRediscoverAlbumsForUserRow, +) []dbq.ListYouMightLikeAlbumsForUserRow { + excluded := mostPlayedAlbumIDs(mostPlayed) + for _, r := range rediscover { + excluded[r.Album.ID] = struct{}{} + } + out := make([]dbq.ListYouMightLikeAlbumsForUserRow, 0, HomeYouMightLikeLimit) + for _, r := range rows { + if _, dup := excluded[r.Album.ID]; dup { + continue + } + out = append(out, r) + if len(out) >= HomeYouMightLikeLimit { + break + } + } + return out +} + +// applyYouMightLikeArtistFilters drops artists the user actively plays +// (Most Played), recently played (Last Played), or that already appear in +// Rediscover, then trims to HomeYouMightLikeLimit. +func applyYouMightLikeArtistFilters( + rows []dbq.ListYouMightLikeArtistsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, + rediscover []dbq.ListRediscoverArtistsForUserRow, + lastPlayed []dbq.ListLastPlayedArtistsForUserRow, +) []dbq.ListYouMightLikeArtistsForUserRow { + excluded := mostPlayedArtistIDs(mostPlayed) + for _, r := range rediscover { + excluded[r.Artist.ID] = struct{}{} + } + for _, r := range lastPlayed { + excluded[r.Artist.ID] = struct{}{} + } + out := make([]dbq.ListYouMightLikeArtistsForUserRow, 0, HomeYouMightLikeLimit) + for _, r := range rows { + if _, dup := excluded[r.Artist.ID]; dup { + continue + } + out = append(out, r) + if len(out) >= HomeYouMightLikeLimit { + break + } + } + return out +} + // mostPlayedAlbumIDs returns the set of album IDs whose tracks appear // in the Most Played row. Used to dedup Rediscover so it doesn't list // albums the user is actively spinning.