// 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 = 25 HomeMostPlayedLimit = 75 HomeLastPlayedLimit = 25 ) // 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 fall back to a random sample of liked entities // when the eligibility query returns fewer than HomeRediscoverLimit rows. // 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 } return out, nil } func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) { primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{ UserID: userID, Limit: HomeRediscoverLimit, }) if err != nil { return nil, err } if len(primary) >= HomeRediscoverLimit { return primary, nil } fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ UserID: userID, Limit: HomeRediscoverLimit, }) 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{ Album: r.Album, ArtistName: r.ArtistName, }) if len(primary) >= HomeRediscoverLimit { 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: HomeRediscoverLimit, }) if err != nil { return nil, err } if len(primary) >= HomeRediscoverLimit { return primary, nil } fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ UserID: userID, Limit: HomeRediscoverLimit, }) 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{ Artist: r.Artist, CoverAlbumID: r.CoverAlbumID, AlbumCount: r.AlbumCount, }) if len(primary) >= HomeRediscoverLimit { break } seen[r.Artist.ID] = struct{}{} } return primary, nil }