feat(recommendation): add HomeData composite service for /api/home
Runs five SQL queries in parallel (recently added albums, most played tracks, last played artists, rediscover albums, rediscover artists) via goroutines with mutex-guarded first-error capture. Rediscover sections merge primary + fallback results, de-duping by pgtype.UUID map key. All HomePayload slices are non-nil so the API handler encodes [] not null. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user