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 } detail := AlbumDetail{ AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec), Tracks: refs, } 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)) } detail := ArtistDetail{ ArtistRef: artistRefFrom(artist, len(rows)), Albums: refs, } 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 } }