Files
minstrel/docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md
T
bvandeusen 14aa9d7fba 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>
2026-04-20 23:33:51 -04:00

11 KiB

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.

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.

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:

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:

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:

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

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

-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';

SearchAlbums already exists; it just needed the count sibling.

tracks.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.ListAlbumsByArtistalbum_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 + CountArtistsMatchingPage[ArtistRef].
  3. Serial: SearchAlbums + CountAlbumsMatchingPage[AlbumRef] (resolve artist names inline).
  4. Serial: SearchTracks + CountTracksMatchingPage[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=newestcreated_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)