feat(library): genre + year quick-jumps on album and artist detail — #367
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s

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.
This commit is contained in:
2026-08-05 13:50:29 -04:00
parent feb1c2eca8
commit a9ca49dc4e
11 changed files with 220 additions and 7 deletions
+10
View File
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
} }
return limit, offset, nil return limit, offset, nil
} }
// nonNilStrings guarantees a JSON array rather than null. The clients iterate
// these without a null check, matching how every other list field in this
// package is emitted.
func nonNilStrings(in []string) []string {
if in == nil {
return []string{}
}
return in
}
+14
View File
@@ -82,9 +82,17 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
refs = append(refs, ref) refs = append(refs, ref)
durSec += ref.DurationSec durSec += ref.DurationSec
} }
// Genre chips are a navigation nicety, so a failure here must not 404 an
// album that loaded fine. Log and ship the detail without them.
genres, err := q.ListGenresForAlbum(r.Context(), album.ID)
if err != nil {
h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID))
genres = nil
}
detail := AlbumDetail{ detail := AlbumDetail{
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec), AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
Tracks: refs, Tracks: refs,
Genres: nonNilStrings(genres),
} }
writeJSON(w, http.StatusOK, detail) writeJSON(w, http.StatusOK, detail)
} }
@@ -114,9 +122,15 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
// durationSec=0: not aggregated for nested album lists per spec data flow. // durationSec=0: not aggregated for nested album lists per spec data flow.
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0)) refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
} }
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
if err != nil {
h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID))
genres = nil
}
detail := ArtistDetail{ detail := ArtistDetail{
ArtistRef: artistRefFrom(artist, len(rows)), ArtistRef: artistRefFrom(artist, len(rows)),
Albums: refs, Albums: refs,
Genres: nonNilStrings(genres),
} }
writeJSON(w, http.StatusOK, detail) writeJSON(w, http.StatusOK, detail)
} }
+7
View File
@@ -89,12 +89,19 @@ type TrackRef struct {
type ArtistDetail struct { type ArtistDetail struct {
ArtistRef ArtistRef
Albums []AlbumRef `json:"albums"` Albums []AlbumRef `json:"albums"`
// Genres carried by this artist's tracks, for quick-jump chips (#367).
// Always non-nil at JSON so the client can iterate without a null check.
Genres []string `json:"genres"`
} }
// AlbumDetail is the response body of GET /api/albums/{id}. // AlbumDetail is the response body of GET /api/albums/{id}.
type AlbumDetail struct { type AlbumDetail struct {
AlbumRef AlbumRef
Tracks []TrackRef `json:"tracks"` Tracks []TrackRef `json:"tracks"`
// Genres carried by this album's tracks, for quick-jump chips (#367).
// Derived from the tracks rather than stored on the album, because genre
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
Genres []string `json:"genres"`
} }
// SearchResponse is the body of GET /api/search. Each facet carries its own // SearchResponse is the body of GET /api/search. Each facet carries its own
+65
View File
@@ -7,6 +7,8 @@ package dbq
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
@@ -212,6 +214,69 @@ func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListA
return items, nil 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 const listGenresWithCount = `-- name: ListGenresWithCount :many
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
FROM tracks FROM tracks
+21
View File
@@ -86,3 +86,24 @@ SELECT COUNT(*) FROM albums
WHERE release_date IS NOT NULL WHERE release_date IS NOT NULL
AND EXTRACT(YEAR FROM release_date)::int AND EXTRACT(YEAR FROM release_date)::int
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int; BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int;
-- name: ListGenresForAlbum :many
-- 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.
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);
-- name: ListGenresForArtist :many
-- 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.
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);
+4
View File
@@ -45,10 +45,14 @@ export type TrackRef = {
export type ArtistDetail = ArtistRef & { export type ArtistDetail = ArtistRef & {
albums: AlbumRef[]; albums: AlbumRef[];
// Genres across this artist's tracks (#367). Server guarantees an array.
genres: string[];
}; };
export type AlbumDetail = AlbumRef & { export type AlbumDetail = AlbumRef & {
tracks: TrackRef[]; tracks: TrackRef[];
// Genres across this album's tracks (#367). Server guarantees an array.
genres: string[];
}; };
export type Playlist = { export type Playlist = {
+2 -2
View File
@@ -91,7 +91,7 @@ describe('AlbumCard', () => {
duration_sec: 545 duration_sec: 545
}) })
]; ];
const detail: AlbumDetail = { ...album, tracks }; const detail: AlbumDetail = { ...album, tracks, genres: [] };
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail); (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
render(AlbumCard, { props: { album } }); render(AlbumCard, { props: { album } });
@@ -112,7 +112,7 @@ describe('AlbumCard', () => {
duration_sec: 545 duration_sec: 545
}) })
]; ];
const detail: AlbumDetail = { ...album, tracks }; const detail: AlbumDetail = { ...album, tracks, genres: [] };
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail); (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
render(AlbumCard, { props: { album } }); render(AlbumCard, { props: { album } });
+24 -1
View File
@@ -126,8 +126,31 @@
<p> <p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a> <a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
</p> </p>
<!-- Year and genre are quick-jumps into the browse axes (#367): from
an album you like, one click to everything else from that year or
in that genre. -->
{#if album.year} {#if album.year}
<p class="text-sm text-text-secondary">{album.year}</p> <p class="text-sm">
<a href={`/library/years?y=${album.year}`} class="text-accent hover:underline">
{album.year}
</a>
</p>
{/if}
{#if album.genres?.length}
<ul class="flex flex-wrap gap-1.5">
{#each album.genres as genre (genre)}
<li>
<a
href={`/library/genres?g=${encodeURIComponent(genre)}`}
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
text-text-secondary hover:bg-surface-hover hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
{genre}
</a>
</li>
{/each}
</ul>
{/if} {/if}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
+51 -2
View File
@@ -49,7 +49,8 @@ describe('album detail page', () => {
year: 1959, track_count: 2, duration_sec: 544 + 565, year: 1959, track_count: 2, duration_sec: 544 + 565,
cover_url: '/api/albums/xyz/cover', cover_url: '/api/albums/xyz/cover',
cover_art_source: null, cover_art_source: null,
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)],
genres: ['Jazz']
}; };
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail })); (createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(AlbumPage); render(AlbumPage);
@@ -77,7 +78,8 @@ describe('album detail page', () => {
track_count: 0, duration_sec: 0, track_count: 0, duration_sec: 0,
cover_url: '/api/albums/xyz/cover', cover_url: '/api/albums/xyz/cover',
cover_art_source: null, cover_art_source: null,
tracks: [] tracks: [],
genres: []
}; };
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail })); (createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(AlbumPage); render(AlbumPage);
@@ -119,4 +121,51 @@ describe('album detail page', () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
// Quick-jumps into the browse axes (#367). The genre href must be encoded:
// a slash-bearing tag is why browse selection lives in the query string.
test('year and genre are quick-jump links into the browse axes', () => {
const detail: AlbumDetail = {
id: 'xyz', title: 'Kind of Blue', sort_title: 'Kind of Blue',
artist_id: 'md', artist_name: 'Miles Davis',
year: 1959, track_count: 0, duration_sec: 0,
cover_url: '/api/albums/xyz/cover',
cover_art_source: null,
tracks: [],
genres: ['Jazz', 'Rock/Pop']
};
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(AlbumPage);
expect(screen.getByRole('link', { name: '1959' })).toHaveAttribute(
'href',
'/library/years?y=1959'
);
expect(screen.getByRole('link', { name: 'Jazz' })).toHaveAttribute(
'href',
'/library/genres?g=Jazz'
);
expect(screen.getByRole('link', { name: 'Rock/Pop' })).toHaveAttribute(
'href',
'/library/genres?g=Rock%2FPop'
);
});
test('no genre chips when the album carries no genre tags', () => {
const detail: AlbumDetail = {
id: 'xyz', title: 'Untagged', sort_title: 'Untagged',
artist_id: 'md', artist_name: 'Miles Davis',
track_count: 0, duration_sec: 0,
cover_url: '/api/albums/xyz/cover',
cover_art_source: null,
tracks: [],
genres: []
};
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(AlbumPage);
expect(screen.queryByRole('link', { name: /library\/genres/ })).toBeNull();
// No year on this fixture either, so no year jump.
expect(screen.queryByRole('link', { name: /^\d{4}$/ })).toBeNull();
});
}); });
+18
View File
@@ -90,6 +90,24 @@
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
</p> </p>
<!-- Genre quick-jumps (#367). No year link here: an artist spans many,
so a single year would be a lie about the discography. -->
{#if detail.genres?.length}
<ul class="mt-2 flex flex-wrap gap-1.5">
{#each detail.genres as genre (genre)}
<li>
<a
href={`/library/genres?g=${encodeURIComponent(genre)}`}
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
text-text-secondary hover:bg-surface-hover hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
{genre}
</a>
</li>
{/each}
</ul>
{/if}
</div> </div>
<button <button
type="button" type="button"
+4 -2
View File
@@ -57,7 +57,8 @@ describe('artist detail page', () => {
test('renders artist name, subtitle, and one AlbumCard per album', () => { test('renders artist name, subtitle, and one AlbumCard per album', () => {
const detail: ArtistDetail = { const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '', id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '',
albums: [album('a1', 'First', 2020), album('a2', 'Second')] albums: [album('a1', 'First', 2020), album('a2', 'Second')],
genres: []
}; };
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail })); (createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(ArtistPage); render(ArtistPage);
@@ -70,7 +71,8 @@ describe('artist detail page', () => {
test('renders top-tracks panel and similar-artists strip when present', () => { test('renders top-tracks panel and similar-artists strip when present', () => {
const detail: ArtistDetail = { const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '', id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)] albums: [album('a1', 'First', 2020)],
genres: []
}; };
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail })); (createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ (createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({