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.
269 lines
8.2 KiB
Go
269 lines
8.2 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: browse.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
)
|
|
|
|
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
|
|
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($1::text)
|
|
)
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) CountAlbumsByGenre(ctx context.Context, genre string) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbumsByGenre, genre)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const countAlbumsByYearRange = `-- name: CountAlbumsByYearRange :one
|
|
SELECT COUNT(*) FROM albums
|
|
WHERE release_date IS NOT NULL
|
|
AND EXTRACT(YEAR FROM release_date)::int
|
|
BETWEEN $1::int AND $2::int
|
|
`
|
|
|
|
type CountAlbumsByYearRangeParams struct {
|
|
YearFrom int32
|
|
YearTo int32
|
|
}
|
|
|
|
func (q *Queries) CountAlbumsByYearRange(ctx context.Context, arg CountAlbumsByYearRangeParams) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbumsByYearRange, arg.YearFrom, arg.YearTo)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many
|
|
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
|
|
`
|
|
|
|
type ListAlbumYearsWithCountRow struct {
|
|
Year int32
|
|
AlbumCount int64
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYearsWithCountRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumYearsWithCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumYearsWithCountRow
|
|
for rows.Next() {
|
|
var i ListAlbumYearsWithCountRow
|
|
if err := rows.Scan(&i.Year, &i.AlbumCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, 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($1::text)
|
|
)
|
|
ORDER BY albums.sort_title, albums.id
|
|
LIMIT $3 OFFSET $2
|
|
`
|
|
|
|
type ListAlbumsByGenreWithArtistParams struct {
|
|
Genre string
|
|
Off int32
|
|
Lim int32
|
|
}
|
|
|
|
type ListAlbumsByGenreWithArtistRow struct {
|
|
Album Album
|
|
ArtistName string
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbumsByGenreWithArtistParams) ([]ListAlbumsByGenreWithArtistRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsByGenreWithArtist, arg.Genre, arg.Off, arg.Lim)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsByGenreWithArtistRow
|
|
for rows.Next() {
|
|
var i ListAlbumsByGenreWithArtistRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, 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 $1::int AND $2::int
|
|
ORDER BY albums.sort_title, albums.id
|
|
LIMIT $4 OFFSET $3
|
|
`
|
|
|
|
type ListAlbumsByYearRangeWithArtistParams struct {
|
|
YearFrom int32
|
|
YearTo int32
|
|
Off int32
|
|
Lim int32
|
|
}
|
|
|
|
type ListAlbumsByYearRangeWithArtistRow struct {
|
|
Album Album
|
|
ArtistName string
|
|
}
|
|
|
|
// Albums released within an inclusive year range, for the albums-page filter.
|
|
func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListAlbumsByYearRangeWithArtistParams) ([]ListAlbumsByYearRangeWithArtistRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsByYearRangeWithArtist,
|
|
arg.YearFrom,
|
|
arg.YearTo,
|
|
arg.Off,
|
|
arg.Lim,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsByYearRangeWithArtistRow
|
|
for rows.Next() {
|
|
var i ListAlbumsByYearRangeWithArtistRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listGenresWithCount = `-- name: ListGenresWithCount :many
|
|
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)
|
|
ORDER BY track_count DESC, trim(g.genre)
|
|
`
|
|
|
|
type ListGenresWithCountRow struct {
|
|
Genre string
|
|
TrackCount int64
|
|
}
|
|
|
|
// 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.
|
|
// Ordered by the expression, not the output alias: `ORDER BY genre` is
|
|
// ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
|
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) {
|
|
rows, err := q.db.Query(ctx, listGenresWithCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListGenresWithCountRow
|
|
for rows.Next() {
|
|
var i ListGenresWithCountRow
|
|
if err := rows.Scan(&i.Genre, &i.TrackCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|