diff --git a/internal/api/api.go b/internal/api/api.go index fa2707f7..5e9a5a03 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/library.go b/internal/api/library.go index da7331ac..08ab15ba 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -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. diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 80e6e7ac..50734c6a 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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 +}