Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user