feat(server): similar-artists + per-user artist top-tracks endpoints

GET /api/artists/{id}/similar — in-library artists ranked by similarity
score (deduped across sources), ArtistRef list with cover + album count.
GET /api/artists/{id}/top-tracks — current user's most-played tracks for
the artist (skips excluded, quarantine filtered).
This commit is contained in:
2026-06-06 22:30:41 -04:00
parent a766b7193f
commit 63b25e65ad
6 changed files with 281 additions and 0 deletions
+2
View File
@@ -86,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
authed.Get("/albums/{id}", h.handleGetAlbum)
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/shuffle", h.handleLibraryShuffle)
+79
View File
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
const (
similarArtistsDefaultLimit = 12
artistTopTracksDefaultLimit = 5
)
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
// in-library artists similar to {id} (ranked by similarity score) as a flat
// ArtistRef list with cover + album count. Empty when the similarity ingest
// has no matches yet; 404 when the artist doesn't exist.
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for similar", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
})
if err != nil {
h.logger.Error("api: list similar artists", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]ArtistRef, 0, len(rows))
for _, row := range rows {
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
}
writeJSON(w, http.StatusOK, out)
}
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
// the current user's most-played tracks for {id} (skips excluded, quarantine
// filtered). Empty when the user hasn't played this artist; 404 when the
// artist doesn't exist.
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
})
if err != nil {
h.logger.Error("api: list artist top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]TrackRef, 0, len(rows))
for _, row := range rows {
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
}
writeJSON(w, http.StatusOK, out)
}
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
// the online source for the client's always-present "Shuffle all"
// (#427 S4). N random tracks across the whole library, per-user