feat(library): genre + year browse queries and endpoints — #367
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s

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.
This commit is contained in:
2026-08-05 13:22:30 -04:00
parent 5b36d79ff9
commit 1126bfcf78
10 changed files with 969 additions and 30 deletions
+22 -5
View File
@@ -61,12 +61,29 @@ SELECT * FROM albums ORDER BY random() LIMIT $1;
-- name: ListAlbumsByGenre :many
-- Album "belongs to" a genre if any of its tracks carry that genre.
SELECT DISTINCT ON (albums.id) albums.*
-- Serves Subsonic getAlbumList?type=byGenre.
--
-- Splits tracks.genre on [;,] as of #367. It previously compared the whole
-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
-- every multi-genre track. This also aligns the endpoint with
-- recommendation.sql / discover.sql, which have always split, and with the
-- genre browse index that #367 adds.
--
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
-- (track, genre-fragment), so a join would multiply rows per album and lean
-- on DISTINCT to undo it. EXISTS asks the question directly.
SELECT albums.*
FROM albums
JOIN tracks ON tracks.album_id = albums.id
WHERE tracks.genre = $1
ORDER BY albums.id, albums.sort_title
LIMIT $2 OFFSET $3;
WHERE EXISTS (
SELECT 1
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.album_id = albums.id
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
)
ORDER BY albums.sort_title, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: SearchAlbums :many
SELECT * FROM albums