diff --git a/internal/api/library.go b/internal/api/library.go index 120e5fec..c34b58e1 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -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") -} diff --git a/internal/api/search.go b/internal/api/search.go new file mode 100644 index 00000000..bb42f910 --- /dev/null +++ b/internal/api/search.go @@ -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 +} diff --git a/internal/api/search_test.go b/internal/api/search_test.go new file mode 100644 index 00000000..43f6b7b6 --- /dev/null +++ b/internal/api/search_test.go @@ -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)) + } +}