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
+88
View File
@@ -0,0 +1,88 @@
-- name: ListGenresWithCount :many
-- Genre browse index (#367).
--
-- Genres live inline on tracks.genre as a delimited string, so this splits on
-- the same [;,] pattern already used by recommendation.sql and discover.sql —
-- a track tagged "Rock;Pop" must count toward both, and diverging from the
-- established pattern here would make the browse surface disagree with what
-- the recommendation engine believes the library contains.
--
-- trim() but deliberately NO lower(): trimming repairs an artifact of OUR
-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
-- would be a bug), whereas case is what the tag actually says. Raw ID3 is
-- exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
--
-- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
-- inflate its own row.
--
-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
-- so alphabetical would bury the handful of genres an operator actually has a
-- library's worth of. Name breaks ties for a stable order.
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE trim(g.genre) <> ''
GROUP BY trim(g.genre)
-- Ordered by the expression, not the output alias: `ORDER BY genre` is
-- ambiguous between the alias and tracks.genre, and sqlc rejects it.
ORDER BY track_count DESC, trim(g.genre);
-- name: ListAlbumsByGenreWithArtist :many
-- Albums for one genre, joined with artist_name for the browse grid.
-- An album belongs to a genre when ANY of its tracks carry it. Splits and
-- trims identically to ListGenresWithCount — if the list is built by
-- splitting and the detail matched exactly, every multi-genre track would
-- produce a genre row that leads to an empty page.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
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: CountAlbumsByGenre :one
-- Total for the paging envelope. EXISTS mirrors the list query exactly; a
-- JOIN + DISTINCT here would count differently the moment an album has two
-- tracks carrying the same genre.
SELECT COUNT(*) FROM albums
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)
);
-- name: ListAlbumYearsWithCount :many
-- Year browse index (#367). Only albums with a release_date appear — an
-- album with no date isn't "year unknown" as a browsable bucket, it's absent
-- from this axis, and the UI says so rather than inventing a 0 row.
-- Newest first: recent releases are the likelier browse target.
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
FROM albums
WHERE release_date IS NOT NULL
GROUP BY year
ORDER BY year DESC;
-- name: ListAlbumsByYearRangeWithArtist :many
-- Albums released within an inclusive year range, for the albums-page filter.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.release_date IS NOT NULL
AND EXTRACT(YEAR FROM albums.release_date)::int
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int
ORDER BY albums.sort_title, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: CountAlbumsByYearRange :one
SELECT COUNT(*) FROM albums
WHERE release_date IS NOT NULL
AND EXTRACT(YEAR FROM release_date)::int
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int;