From 0119eacf14d6cf88dd622ebc4548c0f80c90beee Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 20:41:44 -0400 Subject: [PATCH] feat(api): GET /api/home/index for per-item rendering (Slice B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to /api/home that returns the same five sections (recently added, rediscover albums, rediscover artists, most played, last played) but as flat slices of entity ID strings instead of denormalized objects. The Flutter client uses this to drive its per-item rendering pass — small discovery response then per-tile hydration via the existing /api/albums/:id, /api/artists/:id, /api/tracks/:id endpoints. Reuses recommendation.HomeData so the DB cost is identical to /api/home. JSON payload shrinks roughly an order of magnitude on populated libraries (no embedded title / artist / cover URL fields). Old /api/home stays untouched so the web client and older Flutter builds keep working — no min-client-version bump needed until both clients have migrated. --- internal/api/api.go | 1 + internal/api/home.go | 48 +++++++++++++++++++++++++++++ internal/api/home_test.go | 64 +++++++++++++++++++++++++++++++++++++++ internal/api/types.go | 18 +++++++++++ 4 files changed, 131 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index e85579a8..6e039e5d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -82,6 +82,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/radio", h.handleRadio) authed.Get("/discover/suggestions", h.handleListSuggestions) authed.Get("/home", h.handleGetHome) + authed.Get("/home/index", h.handleGetHomeIndex) authed.Post("/events", h.handleEvents) authed.Get("/events/stream", h.handleEventsStream) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) diff --git a/internal/api/home.go b/internal/api/home.go index c3d2464e..f0f14640 100644 --- a/internal/api/home.go +++ b/internal/api/home.go @@ -49,6 +49,54 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +// handleGetHomeIndex implements GET /api/home/index. Returns the same +// five sections /api/home computes, but as flat slices of entity IDs +// rather than denormalized objects. Drives the Flutter per-item +// rendering path — the client hydrates each tile against the +// per-entity endpoints, so this response is tiny (a few KB at most) +// and the cold-visit round-trip is correspondingly short. +// +// Shares the recommendation.HomeData computation with /api/home; the +// DB cost is identical. A future optimisation could add ID-only +// query variants, but the JSON savings already shrink the wire +// payload by roughly an order of magnitude on populated libraries. +func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + data, err := recommendation.HomeData(r.Context(), h.pool, user.ID) + if err != nil { + h.logger.Error("api: home index data", "err", err) + writeErr(w, apierror.InternalMsg("failed to load home", err)) + return + } + + out := HomeIndexPayload{ + RecentlyAddedAlbums: make([]string, 0, len(data.RecentlyAddedAlbums)), + RediscoverAlbums: make([]string, 0, len(data.RediscoverAlbums)), + RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)), + MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)), + LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)), + } + for _, row := range data.RecentlyAddedAlbums { + out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.RediscoverAlbums { + out.RediscoverAlbums = append(out.RediscoverAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.RediscoverArtists { + out.RediscoverArtists = append(out.RediscoverArtists, uuidToString(row.Artist.ID)) + } + for _, row := range data.MostPlayedTracks { + out.MostPlayedTracks = append(out.MostPlayedTracks, uuidToString(row.Track.ID)) + } + for _, row := range data.LastPlayedArtists { + out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID)) + } + writeJSON(w, http.StatusOK, out) +} + // Compile-time guard that dbq has the row types we need; if the SQL is // regenerated with a different name, this fails fast at build time. var _ = dbq.ListMostPlayedTracksForUserRow{}.Track diff --git a/internal/api/home_test.go b/internal/api/home_test.go index 2f953f9b..123161b5 100644 --- a/internal/api/home_test.go +++ b/internal/api/home_test.go @@ -77,3 +77,67 @@ func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) { t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName) } } + +func newHomeIndexRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/home/index", h.handleGetHomeIndex) + return r +} + +func doGetHomeIndex(h *handlers, user dbq.User) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/home/index", nil) + req = withUser(req, user) + w := httptest.NewRecorder() + newHomeIndexRouter(h).ServeHTTP(w, req) + return w +} + +func TestHomeIndex_EmptyForNewUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + + w := doGetHomeIndex(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var got HomeIndexPayload + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + // Same non-nil slice contract as /api/home so client parsers can + // trust `length == 0` instead of branching on null. + if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil || + got.RediscoverArtists == nil || got.MostPlayedTracks == nil || + got.LastPlayedArtists == nil { + t.Errorf("payload has nil slice: %+v", got) + } + if len(got.RecentlyAddedAlbums) != 0 { + t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums)) + } +} + +func TestHomeIndex_RecentlyAddedReturnsIDs(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "pw", false) + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "ArtistOne", SortName: "ArtistOne", + }) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID, + }) + + w := doGetHomeIndex(h, user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got HomeIndexPayload + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.RecentlyAddedAlbums) != 1 { + t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums)) + } + want := uuidToString(album.ID) + if got.RecentlyAddedAlbums[0] != want { + t.Errorf("id = %q, want %q", got.RecentlyAddedAlbums[0], want) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 9a0a4e02..d9fc0c06 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -118,6 +118,24 @@ type HomePayload struct { LastPlayedArtists []ArtistRef `json:"last_played_artists"` } +// HomeIndexPayload is the response body of GET /api/home/index — the +// per-item rendering variant of /api/home. Same sections, same caps, +// but each section is a flat slice of entity IDs (UUID strings) +// instead of denormalized objects. Slices are non-nil at JSON encode +// time so empty sections render as `[]`. +// +// Client fetches this, then hydrates each tile against the per-entity +// endpoints (/api/albums/{id}, /api/artists/{id}, /api/tracks/{id}) +// for genuine progressive rendering. The section name implies the +// entity type so no per-entry type tag is needed. +type HomeIndexPayload struct { + RecentlyAddedAlbums []string `json:"recently_added_albums"` + RediscoverAlbums []string `json:"rediscover_albums"` + RediscoverArtists []string `json:"rediscover_artists"` + MostPlayedTracks []string `json:"most_played_tracks"` + LastPlayedArtists []string `json:"last_played_artists"` +} + // HistoryEvent is one play in a user's listening history. Used by // /api/me/history; one event per row from play_events. type HistoryEvent struct {