# 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)
