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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user