feat(api): add GET /api/artists/{id}/tracks handler

Wires ListArtistTracksForUser (Task 3 SQL) to a new handler that returns
all quarantine-filtered tracks for an artist as a flat TrackRef list.
404 when the artist doesn't exist. Route registered in Mount().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:06:15 -04:00
parent fc12532dee
commit 2aecbba2ef
3 changed files with 143 additions and 0 deletions
+1
View File
@@ -43,6 +43,7 @@ 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("/albums/{id}", h.handleGetAlbum)
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/tracks/{id}", h.handleGetTrack)
+40
View File
@@ -143,6 +143,46 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
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 := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
return
}
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "artist not found")
return
}
h.logger.Error("api: get artist for tracks", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
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, http.StatusInternalServerError, "server_error", "lookup failed")
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. album_count per artist is
// computed via an N+1 — acceptable at limit=200.
+102
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"io"
"log/slog"
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -465,3 +467,103 @@ func TestRoutesRegisteredInMount(t *testing.T) {
}
}
}
func TestGetArtistTracks_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "pw", false)
q := dbq.New(pool)
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "ArtistA", SortName: "ArtistA",
})
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "AlbumA", SortTitle: "AlbumA", ArtistID: artist.ID,
})
_, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T1", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: "/tmp/m6a-T1.mp3",
FileSize: 1, FileFormat: "mp3",
})
_, _ = q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T2", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: "/tmp/m6a-T2.mp3",
FileSize: 1, FileFormat: "mp3",
})
r := chi.NewRouter()
r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks)
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var got []TrackRef
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ArtistName != "ArtistA" || got[0].AlbumTitle != "AlbumA" {
t.Errorf("got = %+v", got[0])
}
}
func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "pw", false)
q := dbq.New(pool)
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "ArtistB", SortName: "ArtistB",
})
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "AlbumB", SortTitle: "AlbumB", ArtistID: artist.ID,
})
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "Q", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: "/tmp/m6a-Q.mp3",
FileSize: 1, FileFormat: "mp3",
})
if _, err := pool.Exec(context.Background(),
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
user.ID, track.ID,
); err != nil {
t.Fatalf("quarantine insert: %v", err)
}
r := chi.NewRouter()
r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks)
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID)+"/tracks", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var got []TrackRef
_ = json.Unmarshal(w.Body.Bytes(), &got)
if len(got) != 0 {
t.Errorf("len = %d, want 0 (quarantined)", len(got))
}
}
func TestGetArtistTracks_NotFound(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "pw", false)
r := chi.NewRouter()
r.Get("/api/artists/{id}/tracks", h.handleGetArtistTracks)
// Random UUID — no such artist exists.
req := httptest.NewRequest(http.MethodGet, "/api/artists/00000000-0000-0000-0000-000000000000/tracks", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
_ = pool
}