Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
6.2 KiB
Go
187 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.
|
|
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
|
|
}
|