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 (#367): `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, and // the one the task itself cites — 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 }