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 {