feat(api): GET /api/search with three paged facets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 08:29:28 -04:00
parent 8fb2f4e184
commit 683c11661b
3 changed files with 294 additions and 4 deletions
-4
View File
@@ -194,7 +194,3 @@ func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
})
}
// handleSearch is implemented in Task 10 (file: search.go).
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// handleSearch implements GET /api/search. Runs three paged facets sharing
// one limit/offset. Each facet returns its own total reflecting the full
// match count (not just the page). Nil slices are explicitly [] so JSON
// doesn't emit null.
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "q is required")
return
}
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
dbQ := dbq.New(h.pool)
needle := &q
artists, err := dbQ.SearchArtists(r.Context(), dbq.SearchArtistsParams{
Column1: needle, Limit: int32(limit), Offset: int32(offset),
})
if err != nil {
h.logger.Error("api: search artists failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q)
if err != nil {
h.logger.Error("api: count artists matching failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
artistItems := make([]ArtistRef, 0, len(artists))
for _, a := range artists {
albums, aerr := dbQ.ListAlbumsByArtist(r.Context(), a.ID)
if aerr != nil {
h.logger.Error("api: list albums for search artist failed", "err", aerr)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
artistItems = append(artistItems, artistRefFrom(a, len(albums)))
}
albums, err := dbQ.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
Column1: needle, Limit: int32(limit), Offset: int32(offset),
})
if err != nil {
h.logger.Error("api: search albums failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q)
if err != nil {
h.logger.Error("api: count albums matching failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{
Column1: needle, Limit: int32(limit), Offset: int32(offset),
})
if err != nil {
h.logger.Error("api: search tracks failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
trackTotal, err := dbQ.CountTracksMatching(r.Context(), q)
if err != nil {
h.logger.Error("api: count tracks matching failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
return
}
writeJSON(w, http.StatusOK, SearchResponse{
Artists: Page[ArtistRef]{Items: artistItems, Total: int(artistTotal), Limit: limit, Offset: offset},
Albums: Page[AlbumRef]{Items: albumItems, Total: int(albumTotal), Limit: limit, Offset: offset},
Tracks: Page[TrackRef]{Items: trackItems, Total: int(trackTotal), Limit: limit, Offset: offset},
})
}
// resolveAlbumRefs builds AlbumRefs from dbq.Album rows: resolves artist
// name (one query per distinct artist) and track count per album (one
// query each). Acceptable at limit=200 page size.
func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
items := make([]AlbumRef, 0, len(albums))
names, err := resolveArtistNames(ctx, q, collectArtistIDs(albums))
if err != nil {
h.logger.Error("api: resolve artist names failed", "err", err)
return nil, err
}
for _, a := range albums {
count, cerr := q.CountTracksByAlbum(ctx, a.ID)
if cerr != nil {
h.logger.Error("api: count tracks for album failed", "err", cerr)
return nil, cerr
}
items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0))
}
return items, nil
}
// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the
// parent album + artist by id for each track (pair of queries per track).
// At limit=200 this is 400 queries worst case — acceptable for M1.5.
func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) {
items := make([]TrackRef, 0, len(tracks))
for _, t := range tracks {
album, aerr := q.GetAlbumByID(ctx, t.AlbumID)
if aerr != nil {
h.logger.Error("api: get album for track failed", "err", aerr)
return nil, aerr
}
artist, aerr := q.GetArtistByID(ctx, t.ArtistID)
if aerr != nil {
h.logger.Error("api: get artist for track failed", "err", aerr)
return nil, aerr
}
items = append(items, trackRefFrom(t, album.Title, artist.Name))
}
return items, nil
}
// collectArtistIDs extracts the distinct-preserving ordered list of artist
// ids from a slice of albums. resolveArtistNames dedupes internally.
func collectArtistIDs(albums []dbq.Album) []pgtype.UUID {
ids := make([]pgtype.UUID, 0, len(albums))
for _, a := range albums {
ids = append(ids, a.ArtistID)
}
return ids
}
// resolveArtistNames fetches each distinct artist id and returns a
// uuid-string → name map. Duplicated from subsonic intentionally.
func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) {
seen := make(map[string]struct{}, len(ids))
out := make(map[string]string, len(ids))
for _, id := range ids {
key := uuidToString(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
a, err := q.GetArtistByID(ctx, id)
if err != nil {
return nil, err
}
out[key] = a.Name
}
return out, nil
}
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleSearch_ThreeFacets(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "Beatles")
album := seedAlbum(t, pool, artist.ID, "Beatles Greatest", 1982)
seedTrack(t, pool, album.ID, artist.ID, "Beatles Jam", 1, 60_000)
req := httptest.NewRequest(http.MethodGet, "/api/search?q=beatles", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var got SearchResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Artists.Total != 1 || len(got.Artists.Items) != 1 {
t.Errorf("artists = %+v", got.Artists)
}
if got.Albums.Total != 1 || len(got.Albums.Items) != 1 {
t.Errorf("albums = %+v", got.Albums)
}
if got.Tracks.Total != 1 || len(got.Tracks.Items) != 1 {
t.Errorf("tracks = %+v", got.Tracks)
}
if got.Artists.Items[0].Name != "Beatles" {
t.Errorf("artist name = %q", got.Artists.Items[0].Name)
}
if got.Albums.Items[0].ArtistName != "Beatles" {
t.Errorf("album.artist_name = %q", got.Albums.Items[0].ArtistName)
}
if got.Tracks.Items[0].AlbumTitle != "Beatles Greatest" {
t.Errorf("track.album_title = %q", got.Tracks.Items[0].AlbumTitle)
}
}
func TestHandleSearch_NoMatches(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
seedArtist(t, pool, "Beatles")
req := httptest.NewRequest(http.MethodGet, "/api/search?q=nothinghere", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var got SearchResponse
_ = json.Unmarshal(w.Body.Bytes(), &got)
if got.Artists.Total != 0 || got.Albums.Total != 0 || got.Tracks.Total != 0 {
t.Errorf("totals = %+v", got)
}
// All three item slices must JSON-encode as [] not null.
if got.Artists.Items == nil || got.Albums.Items == nil || got.Tracks.Items == nil {
t.Errorf("nil item slice: %+v", got)
}
}
func TestHandleSearch_EmptyQuery(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/search", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleSearch_BlankQuery(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/search?q=%20%20", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleSearch_PagingAppliedPerFacet(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
// Create three matching artists so paging shows.
a1 := seedArtist(t, pool, "Match One")
a2 := seedArtist(t, pool, "Match Two")
a3 := seedArtist(t, pool, "Match Three")
_ = a1
_ = a2
_ = a3
req := httptest.NewRequest(http.MethodGet, "/api/search?q=Match&limit=2&offset=1", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var got SearchResponse
_ = json.Unmarshal(w.Body.Bytes(), &got)
if got.Artists.Total != 3 {
t.Errorf("total = %d, want 3", got.Artists.Total)
}
if got.Artists.Limit != 2 || got.Artists.Offset != 1 {
t.Errorf("page = limit=%d offset=%d", got.Artists.Limit, got.Artists.Offset)
}
if len(got.Artists.Items) != 2 {
t.Errorf("items len = %d, want 2", len(got.Artists.Items))
}
}