Files
minstrel/internal/api/library_browse.go
T
bvandeusen 1126bfcf78
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s
feat(library): genre + year browse queries and endpoints — #367
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.
2026-08-05 13:22:30 -04:00

66 lines
2.3 KiB
Go

package api
import (
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// genreCount is one row of the genre browse index (#367).
//
// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
// alternative is a normalisation table to invent and maintain, and the raw
// spread has to be visible before anyone can judge whether it's a problem.
type genreCount struct {
Genre string `json:"genre"`
TrackCount int `json:"track_count"`
}
// yearCount is one row of the year browse index.
type yearCount struct {
Year int `json:"year"`
AlbumCount int `json:"album_count"`
}
// handleListGenres implements GET /api/library/genres.
//
// Unpaged on purpose. Even a messy library yields hundreds of distinct tag
// strings, not thousands, and the client needs the whole set at once to render
// a browsable index — paging it would mean the UI could only ever show a
// prefix of an ordering the user didn't choose.
func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context())
if err != nil {
h.logger.Error("api: list genres", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]genreCount, 0, len(rows))
for _, row := range rows {
out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)})
}
writeJSON(w, http.StatusOK, out)
}
// handleListAlbumYears implements GET /api/library/years.
//
// Albums with no release_date are absent rather than bucketed under 0 — "year
// unknown" isn't a year, and inventing a row for it would put a fake entry at
// one end of a chronological list.
func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context())
if err != nil {
h.logger.Error("api: list album years", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]yearCount, 0, len(rows))
for _, row := range rows {
out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)})
}
writeJSON(w, http.StatusOK, out)
}