feat(server): "You might like" album/artist Home rows (#790)
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s

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:
2026-06-11 19:33:46 -04:00
parent 4ecb1680bf
commit fdd14ef04c
12 changed files with 919 additions and 1 deletions
+95 -1
View File
@@ -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.