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:
@@ -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)
|
||||
Reference in New Issue
Block a user