// 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 }