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