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
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-newuser")
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("HomeData returned nil payload")
|
||||||
|
}
|
||||||
|
if len(got.RecentlyAddedAlbums) != 0 {
|
||||||
|
t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
|
||||||
|
}
|
||||||
|
if len(got.RediscoverAlbums) != 0 {
|
||||||
|
t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums))
|
||||||
|
}
|
||||||
|
if len(got.RediscoverArtists) != 0 {
|
||||||
|
t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists))
|
||||||
|
}
|
||||||
|
if len(got.MostPlayedTracks) != 0 {
|
||||||
|
t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks))
|
||||||
|
}
|
||||||
|
if len(got.LastPlayedArtists) != 0 {
|
||||||
|
t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assert that pgtype.UUID is a valid map key — used by the
|
||||||
|
// service when de-duping rediscover fallback rows. If this changes, the
|
||||||
|
// service needs to switch to keying by string.
|
||||||
|
var _ = func() pgtype.UUID { return pgtype.UUID{} }
|
||||||
|
|
||||||
|
func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-recents")
|
||||||
|
a1 := seedArtist(t, pool, "ArtistA", "")
|
||||||
|
// Three albums, oldest first — created_at increases naturally with each insert.
|
||||||
|
al1 := seedAlbumForArtist(t, pool, a1.ID, "Older")
|
||||||
|
al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle")
|
||||||
|
al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest")
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.RecentlyAddedAlbums) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums))
|
||||||
|
}
|
||||||
|
if got.RecentlyAddedAlbums[0].Album.ID != al3.ID ||
|
||||||
|
got.RecentlyAddedAlbums[1].Album.ID != al2.ID ||
|
||||||
|
got.RecentlyAddedAlbums[2].Album.ID != al1.ID {
|
||||||
|
t.Errorf("order = [%s, %s, %s], want newest→oldest",
|
||||||
|
got.RecentlyAddedAlbums[0].Album.Title,
|
||||||
|
got.RecentlyAddedAlbums[1].Album.Title,
|
||||||
|
got.RecentlyAddedAlbums[2].Album.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-mostplayed")
|
||||||
|
artist := seedArtist(t, pool, "ArtistB", "")
|
||||||
|
album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB")
|
||||||
|
tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High")
|
||||||
|
tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low")
|
||||||
|
tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined")
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute))
|
||||||
|
}
|
||||||
|
insertPlayEvent(t, pool, user.ID, tLow.ID, now)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertPlayEvent(t, pool, user.ID, tQuar.ID, now)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
||||||
|
user.ID, tQuar.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert quarantine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.MostPlayedTracks) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks))
|
||||||
|
}
|
||||||
|
if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID {
|
||||||
|
t.Errorf("ranking wrong: got [%s, %s], want [High, Low]",
|
||||||
|
got.MostPlayedTracks[0].Track.Title,
|
||||||
|
got.MostPlayedTracks[1].Track.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-rediscover")
|
||||||
|
artist := seedArtist(t, pool, "ArtistC", "")
|
||||||
|
// Like one album NOW (won't satisfy >30d eligibility) — should still
|
||||||
|
// surface via fallback because primary returns 0 rows.
|
||||||
|
al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike")
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`,
|
||||||
|
user.ID, al.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert like: %v", err)
|
||||||
|
}
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.RediscoverAlbums) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums))
|
||||||
|
}
|
||||||
|
if got.RediscoverAlbums[0].Album.ID != al.ID {
|
||||||
|
t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user