feat(server): "You might like" album/artist Home rows (#790)
Surface 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 granularity. Built in the daily 3am BuildSystemPlaylists pass, atomic-replaced alongside the system playlists, and read back by /api/home (+ /api/home/index). Cold-start gate: skips generation entirely below 20 distinct unskipped tracks AND 5 distinct artists, so a thin profile ships empty rows rather than near-random tiles. - migration 0034: you_might_like_albums / you_might_like_artists (id+rank, CASCADE, per-user rank index). - playlists/you_might_like.go: cold-start gate + similarity roll-up (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the same userIDHash jitter as For-You) + atomic-replace persist in the tx. - recommendation/home.go: two new HomePayload sections with read-time cross-section dedup vs Most Played / Rediscover / Last Played, trimmed to 10 each. - api: you_might_like_albums / you_might_like_artists on /api/home and /api/home/index, reusing albumRefFrom / artistRefFromCovered. - tests: pure roll-up/aggregation/cap unit tests + DB-backed gate, sufficiency, and atomic-replace tests (all green vs real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
||||||
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
||||||
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
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 {
|
for _, row := range data.RecentlyAddedAlbums {
|
||||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
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 {
|
for _, row := range data.LastPlayedArtists {
|
||||||
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
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)
|
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)),
|
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
|
||||||
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
||||||
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
|
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 {
|
for _, row := range data.RecentlyAddedAlbums {
|
||||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
|
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 {
|
for _, row := range data.LastPlayedArtists {
|
||||||
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
|
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)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ type HomePayload struct {
|
|||||||
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
||||||
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
||||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
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
|
// HomeIndexPayload is the response body of GET /api/home/index — the
|
||||||
@@ -134,6 +139,8 @@ type HomeIndexPayload struct {
|
|||||||
RediscoverArtists []string `json:"rediscover_artists"`
|
RediscoverArtists []string `json:"rediscover_artists"`
|
||||||
MostPlayedTracks []string `json:"most_played_tracks"`
|
MostPlayedTracks []string `json:"most_played_tracks"`
|
||||||
LastPlayedArtists []string `json:"last_played_artists"`
|
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
|
// HistoryEvent is one play in a user's listening history. Used by
|
||||||
|
|||||||
@@ -549,3 +549,17 @@ type UserInvite struct {
|
|||||||
RedeemedAt pgtype.Timestamptz
|
RedeemedAt pgtype.Timestamptz
|
||||||
RedeemedBy pgtype.UUID
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -497,6 +497,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
|||||||
built = append(built, out...)
|
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.
|
// Atomic replace inside a transaction.
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -530,6 +537,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
|||||||
createdIDs = append(createdIDs, id)
|
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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
buildErr = fmt.Errorf("commit tx: %w", err)
|
buildErr = fmt.Errorf("commit tx: %w", err)
|
||||||
return buildErr
|
return buildErr
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ const (
|
|||||||
// artist from dominating the row. Two is enough variety; higher
|
// artist from dominating the row. Two is enough variety; higher
|
||||||
// reads as "this artist's discography" instead of "rediscover".
|
// reads as "this artist's discography" instead of "rediscover".
|
||||||
maxAlbumsPerArtistInRediscover = 2
|
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
|
// HomePayload is the composite returned by HomeData. All slices are
|
||||||
@@ -45,6 +52,8 @@ type HomePayload struct {
|
|||||||
RediscoverArtists []dbq.ListRediscoverArtistsForUserRow
|
RediscoverArtists []dbq.ListRediscoverArtistsForUserRow
|
||||||
MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow
|
MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow
|
||||||
LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow
|
LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow
|
||||||
|
YouMightLikeAlbums []dbq.ListYouMightLikeAlbumsForUserRow
|
||||||
|
YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// HomeData runs five queries in parallel and assembles the payload.
|
// 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{},
|
RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{},
|
||||||
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
||||||
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
||||||
|
YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{},
|
||||||
|
YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{},
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(5)
|
wg.Add(7)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
|
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
|
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()
|
wg.Wait()
|
||||||
|
|
||||||
if firstErr != nil {
|
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.
|
// the rediscover lists down toward HomeRediscoverLimit.
|
||||||
out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
|
out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
|
||||||
out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, 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
|
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
|
// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear
|
||||||
// in the Most Played row. Used to dedup Rediscover so it doesn't list
|
// in the Most Played row. Used to dedup Rediscover so it doesn't list
|
||||||
// albums the user is actively spinning.
|
// albums the user is actively spinning.
|
||||||
|
|||||||
Reference in New Issue
Block a user