186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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, apierror.BadRequest("bad_request", "q is required"))
|
|
return
|
|
}
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("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, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q)
|
|
if err != nil {
|
|
h.logger.Error("api: count artists matching failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
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, apierror.InternalMsg("search failed", aerr))
|
|
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, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q)
|
|
if err != nil {
|
|
h.logger.Error("api: count albums matching failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
|
|
// userID is NULL for unauthenticated callers; the unified queries
|
|
// skip the quarantine filter when user_id IS NULL.
|
|
var userID pgtype.UUID
|
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
|
userID = user.ID
|
|
}
|
|
tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{
|
|
Column1: q, UserID: userID, Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: search tracks failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
trackTotal, err := dbQ.CountTracksMatching(r.Context(), dbq.CountTracksMatchingParams{
|
|
Column1: q, UserID: userID,
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: count tracks matching failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
return
|
|
}
|
|
trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
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
|
|
}
|
|
// durationSec=0: not aggregated for search album refs per spec data flow.
|
|
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
|
|
}
|