Server half of #367. Web UI follows. Genres are exposed AS-IS per the operator: split on the delimiter, trimmed, but no case folding and no synonym mapping. So "Rock" and "rock" appear as separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread has to be visible before anyone can judge whether it needs normalising, and the alternative is a mapping table to invent and then maintain. Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and showing that as a genre distinct from "Pop" would be a bug in OUR splitting, not fidelity to the operator's tags. ## The correctness trap this had to avoid ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and discover.sql have always split it on [;,]. Building the browse index by splitting while matching exactly would have listed genres whose pages are empty — every multi-genre track unreachable from either of its genres. So ListAlbumsByGenre now splits too. That also fixes Subsonic getAlbumList?type=byGenre, its only caller, which silently missed every multi-genre track. Its Genre param went *string → string as a result. EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one row per (track, fragment), so a join multiplies rows per album and needs DISTINCT to undo itself. EXISTS asks the question directly, and the count query then matches the list query by construction rather than by coincidence. ## Genre is a query parameter, not a path segment Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a slash cannot survive a path segment: Go normalises %2F and the router would split the value in two. So filtering rides GET /api/library/albums?genre=, which also reuses the existing paged album surface instead of adding a parallel one. Endpoints: GET /api/library/genres unpaged index + track counts GET /api/library/years unpaged index + album counts GET /api/library/albums?genre= filtered page GET /api/library/albums?year_from=&year_to= filtered page, either edge open The indexes are unpaged deliberately: a client needs the whole set to render a browsable picker, and paging would let it show only a prefix of an ordering the user didn't choose. Two refusals rather than guesses: genre+year together is a 400 (the UI browses them as separate axes, and quietly dropping half a filter would report a narrower result than it returned), and an inverted year range is a 400 rather than being silently swapped. Undated albums are absent from the year axis rather than bucketed under 0 — "unknown" is not a year, and a 0 row would sort to one end of a chronological list looking like data. Tests: parseYearFilter is pure and runs in the fast lane. The integration tests assert the thing that would otherwise be silently broken — that a "Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a filter value, that fragment whitespace is trimmed, and that undated albums stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre fixtures, which already took exactly the year and genre arguments needed.
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 (#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
|
|
}
|