Plan 2 of the web UI rollout. Covers GET /api/artists (paged, two sort modes), artist/album/track detail, and unified search with enveloped pagination and forward-reference stream/cover URLs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 KiB
Web UI Library Reads — Design
Status: approved (2026-04-20)
Follows: 2026-04-20-web-ui-scaffold-design.md, 2026-04-20-web-ui-server-auth-foundation (shipped)
Precedes: Plan 3 — stream + cover endpoints
Goal
Add the read-only library surface to Minstrel's native JSON API at /api/*. Five endpoints: paginated artist list (two sort modes), artist detail, album detail, track detail, and unified search. This is the data the SvelteKit SPA needs to render browse and search views; streaming and cover art follow in Plan 3.
Scope explicitly excludes: stream, cover, write operations (favorites, play history), admin endpoints beyond what's already mounted.
Routes
All gated by the existing RequireUser middleware inside api.Mount:
GET /api/artists?sort={alpha|newest}&limit=&offset=
GET /api/artists/{id}
GET /api/albums/{id}
GET /api/tracks/{id}
GET /api/search?q=&limit=&offset=
Path-style IDs (chi {id}) — matches REST norms and the SPA router's expectations. Pagination defaults: limit=50, limit clamped to [1,200], offset=0, offset clamped to >=0. Out-of-range values silently clamp (match subsonic ergonomics); malformed values (non-numeric) return 400.
Response Shapes
Two-tier Ref/Detail split mirrors the subsonic package's pattern.
Refs
Lightweight, embedded in list and detail responses.
type ArtistRef struct {
ID string `json:"id"` // UUID string
Name string `json:"name"`
AlbumCount int `json:"album_count"`
}
type AlbumRef struct {
ID string `json:"id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
Year int `json:"year,omitempty"`
TrackCount int `json:"track_count"`
DurationSec int `json:"duration_sec"`
CoverURL string `json:"cover_url"` // /api/albums/{id}/cover — lands in Plan 3
}
type TrackRef struct {
ID string `json:"id"`
Title string `json:"title"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
DurationSec int `json:"duration_sec"`
StreamURL string `json:"stream_url"` // /api/tracks/{id}/stream — Plan 3
}
Details
Wrap a ref with nested children.
type ArtistDetail struct {
ArtistRef
Albums []AlbumRef `json:"albums"`
}
type AlbumDetail struct {
AlbumRef
Tracks []TrackRef `json:"tracks"`
}
// Track detail is just TrackRef — parent names are already embedded.
Pagination Envelope
Shared generic, defined once in types.go:
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
Used by /api/artists and each facet of /api/search.
Search Response
Three independent paged facets sharing one limit/offset:
type SearchResponse struct {
Artists Page[ArtistRef] `json:"artists"`
Albums Page[AlbumRef] `json:"albums"`
Tracks Page[TrackRef] `json:"tracks"`
}
Shared limit/offset (vs subsonic's per-facet params) keeps the SPA simple: one set of pager controls per search page.
JSON conventions
- UUIDs serialize as strings (not
pgtype.UUID) via auuidToStringhelper inconvert.go. - Empty collections always render as
[], nevernull— web clients reject null. Initialize slices explicitly before encoding. - Field names: snake_case everywhere.
Embedded URLs
Forward-reference URLs baked into responses now, even though Plan 3 implements the endpoints:
AlbumRef.CoverURL=/api/albums/{id}/coverTrackRef.StreamURL=/api/tracks/{id}/stream
Built by helpers in convert.go:
func coverURL(albumID pgtype.UUID) string { return "/api/albums/" + uuidToString(albumID) + "/cover" }
func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" }
Relative paths — SPA and Flutter client resolve against configured base URL. Responses stay host-agnostic (reverse-proxy friendly). Until Plan 3, these paths 404; the SPA won't wire <audio src> / <img src> until Plan 3 ships.
SQL Queries
All SELECT-only. No migrations. Add to internal/db/queries/:
artists.sql
-- name: ListArtistsAlpha :many
SELECT * FROM artists ORDER BY sort_name, name LIMIT $1 OFFSET $2;
-- name: ListArtistsNewest :many
SELECT * FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;
-- name: CountArtists :one
SELECT COUNT(*) FROM artists;
-- name: CountArtistsMatching :one
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
ListArtists (no LIMIT) stays — subsonic's buildArtistIndexes still needs it. Don't rewrite that call site; add paginated siblings.
albums.sql
-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
SearchAlbums already exists; it just needed the count sibling.
tracks.sql
-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
Notes
- Secondary ordering (
created_at DESC, id DESC) gives newest sort a stable tiebreaker — prevents pagination drift when two artists share a timestamp. - Two-query pattern (list + count) is acceptable at M1 sizes (thousands of rows). Revisit with window functions if artists exceed ~100k.
Regenerate sqlc output (go generate ./... or the Makefile step) to refresh internal/db/dbq/*.go.
Data Flow
GET /api/artists?sort=alpha
- Parse
limit/offset/sortfrom query; clamp and default. q.ListArtistsAlphaorq.ListArtistsNewest(pick bysort).q.CountArtistsfortotal.- For each artist,
q.ListAlbumsByArtist→album_count. N+1, but atlimit=50acceptable. Revisit with a join query if profiling flags it. - Build
Page[ArtistRef], JSON-encode.
GET /api/artists/{id}
- Parse
{id}as UUID (400 on bad form). GetArtistByID(404 onpgx.ErrNoRows).ListAlbumsByArtist(id)→ album list.- For each album,
CountTracksByAlbum(consistent with subsonic; cheap). - Return
ArtistDetailwith embedded[]AlbumRef.
GET /api/albums/{id}
- Parse
{id}as UUID (400). GetAlbumByID(404).GetArtistByID(album.ArtistID)for artist name.ListTracksByAlbum(id)→ ordered by disc/track number at the query level (existing behavior).- Return
AlbumDetailwith embedded[]TrackRef.
GET /api/tracks/{id}
- Parse
{id}as UUID (400). GetTrackByID(404).GetAlbumByID(track.AlbumID)+GetArtistByID(track.ArtistID)for parent names.- Return
TrackRefdirectly (no wrapping detail type — all fields already present).
GET /api/search?q=foo
- Parse
q(empty → 400),limit,offset. - Serial:
SearchArtists+CountArtistsMatching→Page[ArtistRef]. - Serial:
SearchAlbums+CountAlbumsMatching→Page[AlbumRef](resolve artist names inline). - Serial:
SearchTracks+CountTracksMatching→Page[TrackRef](resolve album + artist names inline). - Return
SearchResponse.
Artist-name resolution in album lists
Reuse the pattern from subsonic's resolveArtistNames: one query per distinct artist id in the page. For a 50-album page this is ≤50 but typically fewer (same artist repeated). Duplicate the pattern inline in internal/api rather than importing from subsonic — tiny helper, clean package boundary.
Error Handling
Reuse existing writeErr(w, code, slug, message) from internal/api/errors.go. The SPA's auth code already parses the {error:{code,message}} envelope.
| Condition | HTTP | code slug |
|---|---|---|
| No cookie/bearer | 401 | unauthorized (from RequireUser) |
| Malformed UUID in path | 400 | bad_request |
| Row not found | 404 | not_found |
Missing q on search |
400 | bad_request |
limit/offset non-numeric |
400 | bad_request |
| DB / query failure | 500 | server_error (logged; message generic) |
Out-of-range limit/offset silently clamp (not 400). Matches subsonic's clampInt ergonomics and means the SPA can send whatever value the user picks without validation.
File Structure
Files to create:
| File | Responsibility |
|---|---|
internal/api/library.go |
Artist/album/track list and detail handlers |
internal/api/search.go |
Unified search handler |
internal/api/convert.go |
uuid↔string, year-from-pgdate, URL builders |
internal/api/library_test.go |
Integration tests for library endpoints |
internal/api/search_test.go |
Integration tests for search |
internal/api/library_fixtures_test.go |
Shared seed helpers (seedArtist, seedAlbum, seedTrack) |
Files to modify:
| File | Change |
|---|---|
internal/api/api.go |
Register new routes inside the authed group |
internal/api/types.go |
Add Page[T], refs, details, SearchResponse |
internal/db/queries/artists.sql |
Add list + count queries |
internal/db/queries/albums.sql |
Add CountAlbumsMatching |
internal/db/queries/tracks.sql |
Add CountTracksMatching |
Regenerated: internal/db/dbq/artists.sql.go, albums.sql.go, tracks.sql.go.
Approach A was chosen during brainstorming: fresh handlers in internal/api, tiny helpers duplicated inline. No shared package with subsonic. The two packages shape responses differently (subsonic wraps in subsonic-response; api returns bare JSON envelopes), and the duplicated helpers are ~5 lines each. Revisit extraction during Plan 3 if stream/cover surfaces real duplication.
Testing
Integration-only, following the auth_test.go pattern:
testHandlers(t)helper already exists (spins handlers againstMINSTREL_TEST_DATABASE_URL, TRUNCATE between tests).library_fixtures_test.goaddsseedArtist,seedAlbum,seedTrackshared across library + search tests.- No unit tests for
convert.gohelpers — trivial, covered through handler tests.
Key test cases per endpoint
GET /api/artists:
- default sort = alpha, paged correctly
sort=newest→created_at DESCordersort=garbage→ 400limit/offsetrespected,totalreflects full count- embedded
album_countmatches fixture
GET /api/artists/{id}:
- happy path with nested albums
- bad UUID → 400
- unknown UUID → 404
GET /api/albums/{id}:
- happy path with tracks + artist name +
cover_urlembedded - tracks ordered by disc/track number
- 400 / 404 parity
GET /api/tracks/{id}:
- happy path with parent names +
stream_urlembedded - 400 / 404
GET /api/search?q=foo:
- three paged facets populated
- empty
q→ 400 - no matches → three
[]facets, totals = 0 - shared
limit/offsetapplied per facet - each facet's
totalreflects full match count
Explicitly out of scope for tests
- Concurrency / race conditions
- Large-result performance (N+1 tolerance, profiling)
- Stream/cover endpoint behavior (Plan 3)
Out of Scope (Plan 3 or later)
GET /api/tracks/{id}/stream— audio delivery with range requestsGET /api/albums/{id}/cover— cover art delivery- Favorites, play history, playlists
- Admin endpoints (scan, user management)
- Multi-library support
- Subsonic client shim (separate track)