Files
minstrel/internal/recommendation/home.go
T
bvandeusen 8b586c2e50
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 2m16s
feat(recommendation): rework Rediscover - track-derived eligibility + daily rotation + caps
User request 2026-06-01: the previous Rediscover only fired on
explicit album/artist likes, so users who only liked at the track
level got an empty Rediscover row. Also no daily rotation - the
section never changed between data updates - and 25 items felt long
for the Home carousel.

SQL (internal/db/queries/recommendation.sql):
- ListRediscoverAlbumsForUser UNIONs the existing explicit album-like
  signal with a new track-derived path: an album qualifies if it has
  >=2 liked tracks where the earliest like is >30 days old.
- ListRediscoverArtistsForUser does the same with threshold 3 (artists
  span more releases, so the bar is higher to avoid one-hit-wonder
  affinities).
- Both ordering switched from longest-since-last-play to a daily-
  stable random hash md5(entity_id || user_id || current_date) so
  the row randomizes but stays consistent within a day.
- Fallback queries also switched to the daily-stable hash so the
  fallback rows don't reshuffle on every refresh.

Go (internal/recommendation/home.go):
- HomeRediscoverLimit dropped from 25 to 10 (carousel-sized).
- rediscoverInnerLimit (30) is the per-query cap; gives headroom so
  the Go-layer filters don't undershoot.
- applyRediscoverAlbumFilters does cross-section dedup vs Most Played
  (no point surfacing albums the user is actively spinning) plus a
  diversity cap of max 2 albums per artist (prevents one liked-but-
  forgotten artist dominating the row).
- applyRediscoverArtistFilters does cross-section dedup vs Most
  Played's artist set; no diversity cap needed (one row per artist).

Tests (internal/recommendation/home_integration_test.go):
- TrackDerived path: 2 liked tracks >30d old + no recent plays = album
  appears in Rediscover.
- Threshold guard: 1 liked track = album does NOT appear.
- Diversity cap: 4 explicit album-likes from same artist = 2 in output.
- Dedup vs MostPlayed: eligible album whose track is in MostPlayed
  gets filtered out.
- Existing FallbackWhenSparse test preserved (semantics unchanged for
  recent likes that miss the >30d primary filter).

Clients unchanged - they render whatever /api/home/index returns. No
parity-map row needed (this is a server-internal recommendation
change, not a layout divergence).
2026-06-01 00:57:40 -04:00

290 lines
9.2 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
)
// 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
}
// 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{},
}
wg.Add(5)
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
}()
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)
return out, nil
}
// 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
}