Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
12 KiB
Go
373 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album +
|
|
// artist names so the response is self-contained — clients should not need
|
|
// a follow-up request to render a track outside an album view.
|
|
func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
|
q := dbq.New(h.pool)
|
|
track, apiErr := resolveByID(r, "id", q.GetTrackByID, "track")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
|
|
if err != nil {
|
|
h.logger.Error("api: get track album failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
|
|
if err != nil {
|
|
h.logger.Error("api: get track artist failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name))
|
|
}
|
|
|
|
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
|
// tracks (ordered by disc/track number via the underlying query) with
|
|
// duration summed from the track list — keeps one source of truth.
|
|
//
|
|
// Two DB round trips: the album+artist join and the per-user tracks
|
|
// list. Down from three (separate album, artist, tracks) before
|
|
// GetAlbumWithArtist landed.
|
|
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
|
q := dbq.New(h.pool)
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := q.GetAlbumWithArtist(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.NotFound("album"))
|
|
return
|
|
}
|
|
h.logger.Error("api: get album+artist failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
album := row.Album
|
|
artistName := row.ArtistName
|
|
var userID pgtype.UUID
|
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
|
userID = user.ID
|
|
}
|
|
tracks, err := q.ListTracksByAlbum(r.Context(), dbq.ListTracksByAlbumParams{
|
|
AlbumID: album.ID, UserID: userID,
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list tracks failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
refs := make([]TrackRef, 0, len(tracks))
|
|
durSec := 0
|
|
for _, t := range tracks {
|
|
ref := trackRefFrom(t, album.Title, artistName)
|
|
refs = append(refs, ref)
|
|
durSec += ref.DurationSec
|
|
}
|
|
// Genre chips are a navigation nicety, so a failure here must not 404 an
|
|
// album that loaded fine. Log and ship the detail without them.
|
|
genres, err := q.ListGenresForAlbum(r.Context(), album.ID)
|
|
if err != nil {
|
|
h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID))
|
|
genres = nil
|
|
}
|
|
detail := AlbumDetail{
|
|
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
|
Tracks: refs,
|
|
Genres: nonNilStrings(genres),
|
|
}
|
|
writeJSON(w, http.StatusOK, detail)
|
|
}
|
|
|
|
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
|
// each album carries its own track_count.
|
|
//
|
|
// Down from 1 + 1 + N queries (artist, albums, per-album CountTracksByAlbum)
|
|
// to 1 + 1 (artist, albums-with-track-count via correlated subquery).
|
|
// On a 30-album artist that's ~32 round trips collapsed to 2 — the
|
|
// difference between "feels slow" and "feels instant" on detail nav.
|
|
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
|
q := dbq.New(h.pool)
|
|
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
rows, err := q.ListAlbumsByArtistWithTrackCount(r.Context(), artist.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: list albums by artist failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
refs := make([]AlbumRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
// durationSec=0: not aggregated for nested album lists per spec data
|
|
// flow.
|
|
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
|
}
|
|
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
|
|
if err != nil {
|
|
h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID))
|
|
genres = nil
|
|
}
|
|
detail := ArtistDetail{
|
|
ArtistRef: artistRefFrom(artist, len(rows)),
|
|
Albums: refs,
|
|
Genres: nonNilStrings(genres),
|
|
}
|
|
if artist.Mbid != nil {
|
|
detail.MBID = *artist.Mbid
|
|
}
|
|
writeJSON(w, http.StatusOK, detail)
|
|
}
|
|
|
|
// handleGetArtistTracks implements GET /api/artists/{id}/tracks. Returns
|
|
// every track the artist has across albums (per-user-quarantine filtered)
|
|
// as a flat list. Used by ArtistCard's play affordance, which shuffles
|
|
// client-side. 404 when the artist doesn't exist.
|
|
func (h *handlers) handleGetArtistTracks(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 tracks", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
rows, err := q.ListArtistTracksForUser(r.Context(), dbq.ListArtistTracksForUserParams{
|
|
ArtistID: id, UserID: user.ID,
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list artist 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)
|
|
}
|
|
|
|
// 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"
|
|
// N random tracks across the whole library, per-user
|
|
// quarantine filtered. limit defaults to 100, clamped to 1..500.
|
|
// Offline, the client shuffles its local cache instead and never
|
|
// calls this.
|
|
func (h *handlers) handleLibraryShuffle(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit := 100
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
limit = n
|
|
}
|
|
}
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
rows, err := dbq.New(h.pool).ListRandomTracksForUser(r.Context(),
|
|
dbq.ListRandomTracksForUserParams{UserID: user.ID, Limit: int32(limit)})
|
|
if err != nil {
|
|
h.logger.Error("api: library shuffle", "err", err)
|
|
writeErr(w, apierror.InternalMsg("shuffle 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)
|
|
}
|
|
|
|
// handleListArtists implements GET /api/artists with two sort modes
|
|
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
|
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
|
// single query. The newest branch keeps its N+1 album_count lookup
|
|
// (less hot path, no covers exposed).
|
|
func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
|
|
sort := r.URL.Query().Get("sort")
|
|
if sort == "" {
|
|
sort = "alpha"
|
|
}
|
|
if sort != "alpha" && sort != "newest" {
|
|
writeErr(w, apierror.BadRequest("bad_request", "sort must be alpha or newest"))
|
|
return
|
|
}
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
|
|
switch sort {
|
|
case "newest":
|
|
artists, err := q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{
|
|
Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list artists newest", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
total, terr := q.CountArtists(r.Context())
|
|
if terr != nil {
|
|
h.logger.Error("api: count artists", "err", terr)
|
|
writeErr(w, apierror.InternalMsg("count failed", terr))
|
|
return
|
|
}
|
|
items := make([]ArtistRef, 0, len(artists))
|
|
for _, a := range artists {
|
|
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
|
if aerr != nil {
|
|
h.logger.Error("api: list albums for artist", "err", aerr)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", aerr))
|
|
return
|
|
}
|
|
items = append(items, artistRefFrom(a, len(albums)))
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[ArtistRef]{
|
|
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
|
})
|
|
return
|
|
|
|
default: // alpha
|
|
rows, err := q.ListArtistsAlphaWithCovers(r.Context(), dbq.ListArtistsAlphaWithCoversParams{
|
|
Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list artists alpha", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
total, terr := q.CountArtists(r.Context())
|
|
if terr != nil {
|
|
h.logger.Error("api: count artists", "err", terr)
|
|
writeErr(w, apierror.InternalMsg("count failed", terr))
|
|
return
|
|
}
|
|
items := make([]ArtistRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[ArtistRef]{
|
|
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
|
})
|
|
return
|
|
}
|
|
}
|