Files
minstrel/internal/db/dbq/browse.sql.go
T
bvandeusen a9ca49dc4e
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s
feat(library): genre + year quick-jumps on album and artist detail — #367
Last bullet of #367. From an album you like, one click to everything else from
that year or in that genre.

Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is
AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So
both detail responses gained a derived `genres` array, computed from the
entity's tracks rather than stored, since an album's tracks can legitimately
disagree about genre.

Split and trimmed identically to the browse index. That's the invariant this
whole task turned on: if the chip's matching diverged from the index's
splitting, a chip would lead to a page that doesn't contain the album you
clicked from.

No year link on artist detail. An artist spans many years, so a single one
would be a lie about the discography — genres only there.

Genre lookup failure is logged and degrades to no chips rather than failing the
request; a navigation nicety must not 404 a detail page that otherwise loaded.
`genres` is always an array at JSON, never null, matching how every other list
field in this package is emitted.

## Type widening, and the TypeScript version of a lesson from earlier today

Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture
that constructs one. Six of them across three test files. That's the same shape
as the Go signature changes that cost three CI rounds in #2453 — change a type,
then go find everything that builds it — so I searched for the constructions
before pushing instead of after. All six updated.

Tests: the encoded href for a slash-bearing genre ("Rock/Pop" →
?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all.
gofmt verified clean via docker rather than guessed.
2026-08-05 13:50:29 -04:00

334 lines
10 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: browse.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
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 listGenresForAlbum = `-- name: ListGenresForAlbum :many
SELECT DISTINCT trim(g.genre) AS genre
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.album_id = $1 AND trim(g.genre) <> ''
ORDER BY trim(g.genre)
`
// Distinct genres carried by an album's tracks, for the album detail page's
// quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
// chip always leads to a page that actually contains this album — the two
// diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listGenresForAlbum, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var genre string
if err := rows.Scan(&genre); err != nil {
return nil, err
}
items = append(items, genre)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGenresForArtist = `-- name: ListGenresForArtist :many
SELECT DISTINCT trim(g.genre) AS genre
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.artist_id = $1 AND trim(g.genre) <> ''
ORDER BY trim(g.genre)
`
// Same, across everything by one artist. Alphabetical rather than by count:
// an artist's genre set is small, and a stable order reads better than a
// frequency ranking nobody asked about.
func (q *Queries) ListGenresForArtist(ctx context.Context, artistID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listGenresForArtist, artistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var genre string
if err := rows.Scan(&genre); err != nil {
return nil, err
}
items = append(items, genre)
}
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
}