Files
minstrel/internal/api/library.go
T
bvandeusen a9ca49dc4e
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s
feat(library): genre + year quick-jumps on album and artist detail — #367
Last bullet of #367. From an album you like, one click to everything else from
that year or in that genre.

Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is
AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So
both detail responses gained a derived `genres` array, computed from the
entity's tracks rather than stored, since an album's tracks can legitimately
disagree about genre.

Split and trimmed identically to the browse index. That's the invariant this
whole task turned on: if the chip's matching diverged from the index's
splitting, a chip would lead to a page that doesn't contain the album you
clicked from.

No year link on artist detail. An artist spans many years, so a single one
would be a lie about the discography — genres only there.

Genre lookup failure is logged and degrades to no chips rather than failing the
request; a navigation nicety must not 404 a detail page that otherwise loaded.
`genres` is always an array at JSON, never null, matching how every other list
field in this package is emitted.

## Type widening, and the TypeScript version of a lesson from earlier today

Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture
that constructs one. Six of them across three test files. That's the same shape
as the Go signature changes that cost three CI rounds in #2453 — change a type,
then go find everything that builds it — so I searched for the constructions
before pushing instead of after. All six updated.

Tests: the encoded href for a slash-bearing genre ("Rock/Pop" →
?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all.
gofmt verified clean via docker rather than guessed.
2026-08-05 13:50:29 -04:00

369 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),
}
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"
// (#427 S4). 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
}
}