From 14aa9d7fba17504d813f4d5061bce7c03fd4e528 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 23:33:51 -0400 Subject: [PATCH 01/23] 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 --- .../2026-04-20-web-ui-library-reads-design.md | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md diff --git a/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md b/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md new file mode 100644 index 00000000..010b2b23 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md @@ -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 `