Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119

Merged
bvandeusen merged 15 commits from dev into main 2026-08-05 15:14:49 -04:00
11 changed files with 220 additions and 7 deletions
Showing only changes of commit a9ca49dc4e - Show all commits
+10
View File
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
}
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)
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{
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
Tracks: refs,
Genres: nonNilStrings(genres),
}
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.
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{
ArtistRef: artistRefFrom(artist, len(rows)),
Albums: refs,
Genres: nonNilStrings(genres),
}
writeJSON(w, http.StatusOK, detail)
}
+7
View File
@@ -89,12 +89,19 @@ type TrackRef struct {
type ArtistDetail struct {
ArtistRef
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}.
type AlbumDetail struct {
AlbumRef
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
+65
View File
@@ -7,6 +7,8 @@ package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
@@ -212,6 +214,69 @@ func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListA
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
+21
View File
@@ -86,3 +86,24 @@ 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;
-- 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 & {
albums: AlbumRef[];
// Genres across this artist's tracks (#367). Server guarantees an array.
genres: string[];
};
export type AlbumDetail = AlbumRef & {
tracks: TrackRef[];
// Genres across this album's tracks (#367). Server guarantees an array.
genres: string[];
};
export type Playlist = {
+2 -2
View File
@@ -91,7 +91,7 @@ describe('AlbumCard', () => {
duration_sec: 545
})
];
const detail: AlbumDetail = { ...album, tracks };
const detail: AlbumDetail = { ...album, tracks, genres: [] };
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
render(AlbumCard, { props: { album } });
@@ -112,7 +112,7 @@ describe('AlbumCard', () => {
duration_sec: 545
})
];
const detail: AlbumDetail = { ...album, tracks };
const detail: AlbumDetail = { ...album, tracks, genres: [] };
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
render(AlbumCard, { props: { album } });
+24 -1
View File
@@ -126,8 +126,31 @@
<p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
</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}
<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}
<p class="text-sm text-text-secondary">
{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,
cover_url: '/api/albums/xyz/cover',
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 }));
render(AlbumPage);
@@ -77,7 +78,8 @@ describe('album detail page', () => {
track_count: 0, duration_sec: 0,
cover_url: '/api/albums/xyz/cover',
cover_art_source: null,
tracks: []
tracks: [],
genres: []
};
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(AlbumPage);
@@ -119,4 +121,51 @@ describe('album detail page', () => {
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">
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
</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>
<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', () => {
const detail: ArtistDetail = {
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 }));
render(ArtistPage);
@@ -70,7 +71,8 @@ describe('artist detail page', () => {
test('renders top-tracks panel and similar-artists strip when present', () => {
const detail: ArtistDetail = {
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 }));
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({