c7adf2c87a
The fallback pulled artists only from explicit artist-likes (general_likes_artists),
but most users like albums and tracks far more than artists — so the artists row
still came up thin (a couple of tiles) even with a rich library, while the albums
row filled fine.
Broaden both fallbacks to "entities you've shown affinity for":
- artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of
liked tracks.
- album fallback = explicit album-likes ∪ albums of liked tracks.
New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the
narrow Rediscover-fallback reuse; same projection so the Go layer still converts
directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes,
so unqualified user_id was ambiguous across the three like tables.)
Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now
fills from their artists (was empty before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
469 lines
16 KiB
Go
469 lines
16 KiB
Go
// home.go is the M6a per-user composite home-page service. Reads five
|
|
// sections (recently added, rediscover albums, rediscover artists, most
|
|
// played tracks, last played artists) in parallel via goroutines and
|
|
// assembles them into a single payload for /api/home.
|
|
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Section size caps. SPA splits these into rows on the page.
|
|
const (
|
|
HomeRecentlyAddedLimit = 50
|
|
// HomeRediscoverLimit is the OUTPUT count clients render. The SQL
|
|
// query asks for rediscoverInnerLimit so the Go layer has headroom
|
|
// to apply cross-section dedup (vs Most Played) and the diversity
|
|
// cap (max 2 albums per artist) without undershooting.
|
|
HomeRediscoverLimit = 10
|
|
HomeMostPlayedLimit = 75
|
|
HomeLastPlayedLimit = 25
|
|
|
|
// rediscoverInnerLimit is the per-query cap; 3x HomeRediscoverLimit
|
|
// gives enough slack that filtering rarely undershoots.
|
|
rediscoverInnerLimit = 30
|
|
|
|
// maxAlbumsPerArtistInRediscover prevents one liked-but-forgotten
|
|
// 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
|
|
// non-nil at return time so the API handler can encode `[]` rather than
|
|
// `null` for empty sections.
|
|
type HomePayload struct {
|
|
RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow
|
|
RediscoverAlbums []dbq.ListRediscoverAlbumsForUserRow
|
|
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.
|
|
// Rediscover sections fan out to inner-limit results (track-derived +
|
|
// explicit-like eligibility, plus a daily-stable random fallback when
|
|
// the eligibility query is sparse), then the Go layer trims to
|
|
// HomeRediscoverLimit after applying cross-section dedup (vs Most
|
|
// Played) and the per-artist diversity cap. Any single query error
|
|
// fails the whole call (predictable empty-or-full UX for the SPA —
|
|
// partial payloads are not a feature).
|
|
func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) {
|
|
q := dbq.New(pool)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
firstErr error
|
|
)
|
|
fail := func(stage string, err error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if firstErr == nil {
|
|
firstErr = fmt.Errorf("home: %s: %w", stage, err)
|
|
}
|
|
}
|
|
|
|
out := &HomePayload{
|
|
RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{},
|
|
RediscoverAlbums: []dbq.ListRediscoverAlbumsForUserRow{},
|
|
RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{},
|
|
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
|
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
|
YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{},
|
|
YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{},
|
|
}
|
|
|
|
wg.Add(7)
|
|
go func() {
|
|
defer wg.Done()
|
|
rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
|
|
if err != nil {
|
|
fail("recently_added", err)
|
|
return
|
|
}
|
|
out.RecentlyAddedAlbums = rows
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{
|
|
UserID: userID, Limit: HomeMostPlayedLimit,
|
|
})
|
|
if err != nil {
|
|
fail("most_played", err)
|
|
return
|
|
}
|
|
out.MostPlayedTracks = rows
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{
|
|
UserID: userID, Limit: HomeLastPlayedLimit,
|
|
})
|
|
if err != nil {
|
|
fail("last_played", err)
|
|
return
|
|
}
|
|
out.LastPlayedArtists = rows
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
rows, err := loadRediscoverAlbums(ctx, q, userID)
|
|
if err != nil {
|
|
fail("rediscover_albums", err)
|
|
return
|
|
}
|
|
out.RediscoverAlbums = rows
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
rows, err := loadRediscoverArtists(ctx, q, userID)
|
|
if err != nil {
|
|
fail("rediscover_artists", err)
|
|
return
|
|
}
|
|
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 {
|
|
return nil, firstErr
|
|
}
|
|
|
|
// Cross-section dedup + diversity cap, applied after the parallel
|
|
// fan-out so the SQL stays simple and reusable. Both filters trim
|
|
// 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)
|
|
// The taste roll-up surfaces top-similar entities, which for a heavy
|
|
// listener are mostly ones they already play — so the dedup above can
|
|
// leave the section thin (often a single artist). Top up from the much
|
|
// larger pool of liked entities, applying the same exclusions so the
|
|
// fallback neither duplicates a tile nor suggests an actively-played one.
|
|
if len(out.YouMightLikeArtists) < HomeYouMightLikeLimit {
|
|
out.YouMightLikeArtists = topUpYouMightLikeArtists(ctx, q, userID, out)
|
|
}
|
|
if len(out.YouMightLikeAlbums) < HomeYouMightLikeLimit {
|
|
out.YouMightLikeAlbums = topUpYouMightLikeAlbums(ctx, q, userID, out)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// youMightLikeFallbackFetch is the liked-entity pool the fallback draws from.
|
|
// Generous so the dedup still leaves enough to fill the row.
|
|
const youMightLikeFallbackFetch = 100
|
|
|
|
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
|
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
|
// artists of liked albums and liked tracks (most users like albums/tracks far
|
|
// more than artists, so the broad pool is what actually fills the row).
|
|
// Excludes what's already shown on Home (the section itself, Rediscover,
|
|
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
|
|
// actively-played artist. Best-effort: a query error leaves the section as-is.
|
|
func topUpYouMightLikeArtists(
|
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
|
) []dbq.ListYouMightLikeArtistsForUserRow {
|
|
excluded := mostPlayedArtistIDs(out.MostPlayedTracks)
|
|
for _, r := range out.RediscoverArtists {
|
|
excluded[r.Artist.ID] = struct{}{}
|
|
}
|
|
for _, r := range out.LastPlayedArtists {
|
|
excluded[r.Artist.ID] = struct{}{}
|
|
}
|
|
result := out.YouMightLikeArtists
|
|
for _, r := range result {
|
|
excluded[r.Artist.ID] = struct{}{}
|
|
}
|
|
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
|
})
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, r := range fb {
|
|
if _, dup := excluded[r.Artist.ID]; dup {
|
|
continue
|
|
}
|
|
result = append(result, dbq.ListYouMightLikeArtistsForUserRow(r))
|
|
if len(result) >= HomeYouMightLikeLimit {
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// topUpYouMightLikeAlbums is the album-row counterpart to
|
|
// topUpYouMightLikeArtists, drawing from the user's liked albums.
|
|
func topUpYouMightLikeAlbums(
|
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
|
) []dbq.ListYouMightLikeAlbumsForUserRow {
|
|
excluded := mostPlayedAlbumIDs(out.MostPlayedTracks)
|
|
for _, r := range out.RediscoverAlbums {
|
|
excluded[r.Album.ID] = struct{}{}
|
|
}
|
|
result := out.YouMightLikeAlbums
|
|
for _, r := range result {
|
|
excluded[r.Album.ID] = struct{}{}
|
|
}
|
|
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
|
})
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, r := range fb {
|
|
if _, dup := excluded[r.Album.ID]; dup {
|
|
continue
|
|
}
|
|
result = append(result, dbq.ListYouMightLikeAlbumsForUserRow(r))
|
|
if len(result) >= HomeYouMightLikeLimit {
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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.
|
|
func mostPlayedAlbumIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} {
|
|
out := make(map[pgtype.UUID]struct{}, len(rows))
|
|
for _, r := range rows {
|
|
out[r.Track.AlbumID] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mostPlayedArtistIDs returns the set of artist IDs whose tracks appear
|
|
// in the Most Played row.
|
|
func mostPlayedArtistIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} {
|
|
out := make(map[pgtype.UUID]struct{}, len(rows))
|
|
for _, r := range rows {
|
|
out[r.Track.ArtistID] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyRediscoverAlbumFilters trims the inner-limit query result down
|
|
// to HomeRediscoverLimit via:
|
|
// 1. Cross-section dedup vs Most Played (drop albums actively played).
|
|
// 2. Diversity cap: at most maxAlbumsPerArtistInRediscover per artist.
|
|
// 3. Final trim to HomeRediscoverLimit.
|
|
//
|
|
// Input order is preserved (daily-stable hash from the SQL), so the
|
|
// row stays stable for the day even after filtering.
|
|
func applyRediscoverAlbumFilters(
|
|
rows []dbq.ListRediscoverAlbumsForUserRow,
|
|
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
|
) []dbq.ListRediscoverAlbumsForUserRow {
|
|
excludedAlbums := mostPlayedAlbumIDs(mostPlayed)
|
|
perArtist := make(map[pgtype.UUID]int, len(rows))
|
|
out := make([]dbq.ListRediscoverAlbumsForUserRow, 0, HomeRediscoverLimit)
|
|
for _, r := range rows {
|
|
if _, dup := excludedAlbums[r.Album.ID]; dup {
|
|
continue
|
|
}
|
|
if perArtist[r.Album.ArtistID] >= maxAlbumsPerArtistInRediscover {
|
|
continue
|
|
}
|
|
perArtist[r.Album.ArtistID]++
|
|
out = append(out, r)
|
|
if len(out) >= HomeRediscoverLimit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyRediscoverArtistFilters trims the artist list via cross-section
|
|
// dedup vs Most Played, then truncates to HomeRediscoverLimit.
|
|
// No diversity cap needed (one row per artist by construction).
|
|
func applyRediscoverArtistFilters(
|
|
rows []dbq.ListRediscoverArtistsForUserRow,
|
|
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
|
) []dbq.ListRediscoverArtistsForUserRow {
|
|
excludedArtists := mostPlayedArtistIDs(mostPlayed)
|
|
out := make([]dbq.ListRediscoverArtistsForUserRow, 0, HomeRediscoverLimit)
|
|
for _, r := range rows {
|
|
if _, dup := excludedArtists[r.Artist.ID]; dup {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
if len(out) >= HomeRediscoverLimit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loadRediscoverAlbums runs the primary eligibility query (track-
|
|
// derived + explicit album-likes UNION'd) and tops up with the
|
|
// fallback (random sample of any liked albums) if the primary returns
|
|
// fewer than rediscoverInnerLimit. Both use the same daily-stable
|
|
// hash ordering. The Go layer (applyRediscoverAlbumFilters) trims to
|
|
// HomeRediscoverLimit after cross-section dedup + diversity cap.
|
|
func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) {
|
|
primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{
|
|
UserID: userID, Limit: rediscoverInnerLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(primary) >= rediscoverInnerLimit {
|
|
return primary, nil
|
|
}
|
|
fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
|
|
UserID: userID, Limit: rediscoverInnerLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen := make(map[pgtype.UUID]struct{}, len(primary))
|
|
for _, r := range primary {
|
|
seen[r.Album.ID] = struct{}{}
|
|
}
|
|
for _, r := range fallback {
|
|
if _, dup := seen[r.Album.ID]; dup {
|
|
continue
|
|
}
|
|
primary = append(primary, dbq.ListRediscoverAlbumsForUserRow(r))
|
|
if len(primary) >= rediscoverInnerLimit {
|
|
break
|
|
}
|
|
seen[r.Album.ID] = struct{}{}
|
|
}
|
|
return primary, nil
|
|
}
|
|
|
|
func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) {
|
|
primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{
|
|
UserID: userID, Limit: rediscoverInnerLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(primary) >= rediscoverInnerLimit {
|
|
return primary, nil
|
|
}
|
|
fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
|
|
UserID: userID, Limit: rediscoverInnerLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen := make(map[pgtype.UUID]struct{}, len(primary))
|
|
for _, r := range primary {
|
|
seen[r.Artist.ID] = struct{}{}
|
|
}
|
|
for _, r := range fallback {
|
|
if _, dup := seen[r.Artist.ID]; dup {
|
|
continue
|
|
}
|
|
primary = append(primary, dbq.ListRediscoverArtistsForUserRow(r))
|
|
if len(primary) >= rediscoverInnerLimit {
|
|
break
|
|
}
|
|
seen[r.Artist.ID] = struct{}{}
|
|
}
|
|
return primary, nil
|
|
}
|