package api import ( "context" "net/http" "strings" "github.com/jackc/pgx/v5/pgtype" "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, 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 } var tracks []dbq.Track if user, ok := auth.UserFromContext(r.Context()); ok { tracks, err = dbQ.SearchTracksForUser(r.Context(), dbq.SearchTracksForUserParams{ Column1: needle, UserID: user.ID, Limit: int32(limit), Offset: int32(offset), }) } else { 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 } var trackTotal int64 if user, ok := auth.UserFromContext(r.Context()); ok { trackTotal, err = dbQ.CountTracksMatchingForUser(r.Context(), dbq.CountTracksMatchingForUserParams{ Column1: q, UserID: user.ID, }) } else { 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 } // 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 }