docs(spec): add design for library reads + search endpoints

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>
This commit is contained in:
2026-04-20 23:33:51 -04:00
parent 728c852ba1
commit 14aa9d7fba
@@ -0,0 +1,319 @@
# 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.
```go
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.
```go
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`:
```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`:
```go
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 a `uuidToString` helper in `convert.go`.
- Empty collections always render as `[]`, never `null` — 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}/cover`
- `TrackRef.StreamURL` = `/api/tracks/{id}/stream`
Built by helpers in `convert.go`:
```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
```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
```sql
-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
```
`SearchAlbums` already exists; it just needed the count sibling.
### tracks.sql
```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`
1. Parse `limit`/`offset`/`sort` from query; clamp and default.
2. `q.ListArtistsAlpha` or `q.ListArtistsNewest` (pick by `sort`).
3. `q.CountArtists` for `total`.
4. For each artist, `q.ListAlbumsByArtist``album_count`. N+1, but at `limit=50` acceptable. Revisit with a join query if profiling flags it.
5. Build `Page[ArtistRef]`, JSON-encode.
### `GET /api/artists/{id}`
1. Parse `{id}` as UUID (400 on bad form).
2. `GetArtistByID` (404 on `pgx.ErrNoRows`).
3. `ListAlbumsByArtist(id)` → album list.
4. For each album, `CountTracksByAlbum` (consistent with subsonic; cheap).
5. Return `ArtistDetail` with embedded `[]AlbumRef`.
### `GET /api/albums/{id}`
1. Parse `{id}` as UUID (400).
2. `GetAlbumByID` (404).
3. `GetArtistByID(album.ArtistID)` for artist name.
4. `ListTracksByAlbum(id)` → ordered by disc/track number at the query level (existing behavior).
5. Return `AlbumDetail` with embedded `[]TrackRef`.
### `GET /api/tracks/{id}`
1. Parse `{id}` as UUID (400).
2. `GetTrackByID` (404).
3. `GetAlbumByID(track.AlbumID)` + `GetArtistByID(track.ArtistID)` for parent names.
4. Return `TrackRef` directly (no wrapping detail type — all fields already present).
### `GET /api/search?q=foo`
1. Parse `q` (empty → 400), `limit`, `offset`.
2. Serial: `SearchArtists` + `CountArtistsMatching``Page[ArtistRef]`.
3. Serial: `SearchAlbums` + `CountAlbumsMatching``Page[AlbumRef]` (resolve artist names inline).
4. Serial: `SearchTracks` + `CountTracksMatching``Page[TrackRef]` (resolve album + artist names inline).
5. 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 against `MINSTREL_TEST_DATABASE_URL`, TRUNCATE between tests).
- `library_fixtures_test.go` adds `seedArtist`, `seedAlbum`, `seedTrack` shared across library + search tests.
- No unit tests for `convert.go` helpers — trivial, covered through handler tests.
### Key test cases per endpoint
**`GET /api/artists`:**
- default sort = alpha, paged correctly
- `sort=newest``created_at DESC` order
- `sort=garbage` → 400
- `limit`/`offset` respected, `total` reflects full count
- embedded `album_count` matches 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_url` embedded
- tracks ordered by disc/track number
- 400 / 404 parity
**`GET /api/tracks/{id}`:**
- happy path with parent names + `stream_url` embedded
- 400 / 404
**`GET /api/search?q=foo`:**
- three paged facets populated
- empty `q` → 400
- no matches → three `[]` facets, totals = 0
- shared `limit`/`offset` applied per facet
- each facet's `total` reflects 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 requests
- `GET /api/albums/{id}/cover` — cover art delivery
- Favorites, play history, playlists
- Admin endpoints (scan, user management)
- Multi-library support
- Subsonic client shim (separate track)