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>
190 lines
5.8 KiB
Go
190 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Widest plausible bounds for an open-ended year filter. A missing year_from
|
|
// means "from the beginning" rather than "from year zero of the query", and
|
|
// likewise for year_to, so the caller can filter on one edge only.
|
|
const (
|
|
minBrowseYear = 0
|
|
maxBrowseYear = 9999
|
|
)
|
|
|
|
var (
|
|
errBadYear = errors.New("year_from and year_to must be integers")
|
|
errInvertedYearRange = errors.New("year_from must not be greater than year_to")
|
|
)
|
|
|
|
// yearFilter carries a parsed, validated inclusive year range. active is false
|
|
// when the request asked for no year filtering at all — distinct from a range
|
|
// that happens to cover everything, because the two take different code paths.
|
|
type yearFilter struct {
|
|
from int32
|
|
to int32
|
|
active bool
|
|
}
|
|
|
|
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
|
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
|
// the SPA infinite-scrolls against this endpoint via TanStack
|
|
// createInfiniteQuery.
|
|
//
|
|
// Optional filters: `genre` and `year_from`/`year_to`.
|
|
//
|
|
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
|
|
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag —
|
|
// which cannot survive a path segment: Go
|
|
// normalises %2F and the router would split the value into two segments.
|
|
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
|
|
years, err := parseYearFilter(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
if genre != "" && years.active {
|
|
// Refused rather than silently honouring one: the UI browses these as
|
|
// separate axes (a genres page, a year filter on the albums page), so
|
|
// the combination can only arrive from a caller that has misunderstood
|
|
// the contract — and quietly dropping half a filter would report a
|
|
// narrower result set than it actually returned.
|
|
writeErr(w, apierror.BadRequest("unsupported_filter_combination",
|
|
"genre and year filters cannot be combined"))
|
|
return
|
|
}
|
|
|
|
q := dbq.New(h.pool)
|
|
var (
|
|
items []AlbumRef
|
|
total int64
|
|
)
|
|
switch {
|
|
case genre != "":
|
|
items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset)
|
|
case years.active:
|
|
items, total, err = albumsByYear(r.Context(), q, years, limit, offset)
|
|
default:
|
|
items, total, err = albumsAlpha(r.Context(), q, limit, offset)
|
|
}
|
|
if err != nil {
|
|
h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
|
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
|
})
|
|
}
|
|
|
|
func albumsAlpha(
|
|
ctx context.Context, q *dbq.Queries, limit, offset int,
|
|
) ([]AlbumRef, int64, error) {
|
|
rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{
|
|
Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total, err := q.CountAlbums(ctx)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items := make([]AlbumRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func albumsByGenre(
|
|
ctx context.Context, q *dbq.Queries, genre string, limit, offset int,
|
|
) ([]AlbumRef, int64, error) {
|
|
rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{
|
|
Genre: genre, Lim: int32(limit), Off: int32(offset),
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total, err := q.CountAlbumsByGenre(ctx, genre)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items := make([]AlbumRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func albumsByYear(
|
|
ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int,
|
|
) ([]AlbumRef, int64, error) {
|
|
rows, err := q.ListAlbumsByYearRangeWithArtist(ctx,
|
|
dbq.ListAlbumsByYearRangeWithArtistParams{
|
|
YearFrom: years.from, YearTo: years.to,
|
|
Lim: int32(limit), Off: int32(offset),
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{
|
|
YearFrom: years.from, YearTo: years.to,
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items := make([]AlbumRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
// parseYearFilter reads year_from / year_to. Either may be omitted, which
|
|
// leaves that edge open — filtering "everything before 1990" shouldn't
|
|
// require inventing a lower bound.
|
|
func parseYearFilter(raw url.Values) (yearFilter, error) {
|
|
fromRaw := strings.TrimSpace(raw.Get("year_from"))
|
|
toRaw := strings.TrimSpace(raw.Get("year_to"))
|
|
if fromRaw == "" && toRaw == "" {
|
|
return yearFilter{}, nil
|
|
}
|
|
f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true}
|
|
if fromRaw != "" {
|
|
n, err := strconv.Atoi(fromRaw)
|
|
if err != nil {
|
|
return yearFilter{}, errBadYear
|
|
}
|
|
f.from = int32(n)
|
|
}
|
|
if toRaw != "" {
|
|
n, err := strconv.Atoi(toRaw)
|
|
if err != nil {
|
|
return yearFilter{}, errBadYear
|
|
}
|
|
f.to = int32(n)
|
|
}
|
|
if f.from > f.to {
|
|
// Rejected rather than swapped: silently reordering would return
|
|
// results for a range the caller didn't ask for, and an inverted
|
|
// range is far more likely a bug than an intent.
|
|
return yearFilter{}, errInvertedYearRange
|
|
}
|
|
return f, nil
|
|
}
|