From d25b1f92913a3d77dd4a8281078aff869f45fae7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 09:06:43 -0400 Subject: [PATCH] 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 --- internal/recommendation/home.go | 195 ++++++++++++++++++ .../recommendation/home_integration_test.go | 130 ++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 internal/recommendation/home.go create mode 100644 internal/recommendation/home_integration_test.go diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go new file mode 100644 index 00000000..4ad696c4 --- /dev/null +++ b/internal/recommendation/home.go @@ -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 +} diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go new file mode 100644 index 00000000..d2de02d5 --- /dev/null +++ b/internal/recommendation/home_integration_test.go @@ -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) + } +}