14aa9d7fba
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>
320 lines
11 KiB
Markdown
320 lines
11 KiB
Markdown
# 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)
|