fdd14ef04c
Surface in-library albums/artists the listener doesn't actively spin but is predicted to enjoy, derived from the same similarity + like-weighted candidate engine that powers For-You — rolled up from track scores to album/artist granularity. Built in the daily 3am BuildSystemPlaylists pass, atomic-replaced alongside the system playlists, and read back by /api/home (+ /api/home/index). Cold-start gate: skips generation entirely below 20 distinct unskipped tracks AND 5 distinct artists, so a thin profile ships empty rows rather than near-random tiles. - migration 0034: you_might_like_albums / you_might_like_artists (id+rank, CASCADE, per-user rank index). - playlists/you_might_like.go: cold-start gate + similarity roll-up (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the same userIDHash jitter as For-You) + atomic-replace persist in the tx. - recommendation/home.go: two new HomePayload sections with read-time cross-section dedup vs Most Played / Rediscover / Last Played, trimmed to 10 each. - api: you_might_like_albums / you_might_like_artists on /api/home and /api/home/index, reusing albumRefFrom / artistRefFromCovered. - tests: pure roll-up/aggregation/cap unit tests + DB-backed gate, sufficiency, and atomic-replace tests (all green vs real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
5.1 KiB
Go
119 lines
5.1 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// handleGetHome implements GET /api/home. Returns five sections in one
|
|
// payload; sections that have no rows render as []. 500 on any underlying
|
|
// DB failure — the SPA should render either empty states or full content,
|
|
// not a half-broken page.
|
|
func (h *handlers) handleGetHome(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 data", "err", err)
|
|
writeErr(w, apierror.InternalMsg("failed to load home", err))
|
|
return
|
|
}
|
|
|
|
out := HomePayload{
|
|
RecentlyAddedAlbums: make([]AlbumRef, 0, len(data.RecentlyAddedAlbums)),
|
|
RediscoverAlbums: make([]AlbumRef, 0, len(data.RediscoverAlbums)),
|
|
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
|
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
|
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
|
YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)),
|
|
YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)),
|
|
}
|
|
for _, row := range data.RecentlyAddedAlbums {
|
|
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
for _, row := range data.RediscoverAlbums {
|
|
out.RediscoverAlbums = append(out.RediscoverAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
for _, row := range data.RediscoverArtists {
|
|
out.RediscoverArtists = append(out.RediscoverArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
|
}
|
|
for _, row := range data.MostPlayedTracks {
|
|
out.MostPlayedTracks = append(out.MostPlayedTracks, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
|
}
|
|
for _, row := range data.LastPlayedArtists {
|
|
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
|
}
|
|
for _, row := range data.YouMightLikeAlbums {
|
|
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
for _, row := range data.YouMightLikeArtists {
|
|
out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
|
}
|
|
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)),
|
|
YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)),
|
|
YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)),
|
|
}
|
|
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))
|
|
}
|
|
for _, row := range data.YouMightLikeAlbums {
|
|
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID))
|
|
}
|
|
for _, row := range data.YouMightLikeArtists {
|
|
out.YouMightLikeArtists = append(out.YouMightLikeArtists, 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
|