Merge pull request 'feat: /api/* library reads, search, and media endpoints' (#15) from dev into main
This commit was merged in pull request #15.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 `<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)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Web UI Media Endpoints — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Status:** Design approved
|
||||||
|
**Follows:** [Web UI Library Reads](2026-04-20-web-ui-library-reads-design.md) — this spec implements the `cover_url` and `stream_url` forward references shipped in Plan 2.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship the two byte-serving endpoints the web SPA (and later the Flutter client) need to render album art and play tracks. When this lands, every `<img src>` and `<audio src>` tag that Plan 2's DTOs produced will resolve.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Explicit YAGNI — documented here so they don't drift back in during implementation:
|
||||||
|
|
||||||
|
- No cover resizing (`?size=` param). Serving one size matches subsonic's current behavior; re-sizing is a future optimization when the SPA shows it's needed.
|
||||||
|
- No audio transcoding (`?format=`, `?maxBitRate=`). Raw bytes only.
|
||||||
|
- No `/download` attachment-mode variant. `/stream` is enough for the SPA; a separate download endpoint can land later if a concrete caller needs it.
|
||||||
|
- No scanner changes to populate `albums.cover_art_path`. The sidecar fallback covers the common real-world case today; improving scanner coverage is its own plan.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two endpoints, both under `RequireUser` in the existing `/api` Mount:
|
||||||
|
|
||||||
|
- `GET /api/albums/{id}/cover` → album cover image
|
||||||
|
- `GET /api/tracks/{id}/stream` → raw audio bytes, Range-capable
|
||||||
|
|
||||||
|
**Package isolation.** `internal/subsonic/stream.go` already implements the same two behaviors for `/rest/*`. Per project direction, subsonic is long-term legacy and stays frozen. The protocol-agnostic helpers (mime tables, sidecar lookup, `http.ServeContent` glue) are duplicated into `internal/api/media.go` rather than extracted into a shared package. The only shared layer is `internal/db/dbq` (already) and `http.ServeContent` from stdlib.
|
||||||
|
|
||||||
|
**Auth.** No new middleware. `auth.RequireUser` (in `internal/auth/session.go`) already accepts both the `minstrel_session` cookie and `Authorization: Bearer <token>`. Browser `<img>` and `<audio>` tags send cookies automatically; the Flutter client will carry the bearer token.
|
||||||
|
|
||||||
|
**Range / ETag / If-Modified-Since.** Delegated to `http.ServeContent`, which reads headers from the request and writes the correct 206/304/200 response plus `Content-Range` / `ETag` / `Last-Modified`. No hand-rolled Range parsing.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Method | Path | Auth | Success response |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/albums/{id}/cover` | `RequireUser` | 200 (or 304) with image bytes; `Content-Type` inferred from file extension (`image/jpeg`, `image/png`, `image/webp`, `image/gif`) |
|
||||||
|
| GET | `/api/tracks/{id}/stream` | `RequireUser` | 200 / 206 / 304 with audio bytes; `Content-Type` from `track.file_format` (e.g. `audio/mpeg`, `audio/flac`, `audio/ogg`); `Accept-Ranges: bytes`; `Content-Length` set by `http.ServeContent` |
|
||||||
|
|
||||||
|
Both routes register inside the existing `authed` chi group in `internal/api/api.go` (alongside the Plan 2 library routes). Production `Mount` wires them; the test-only `newLibraryRouter` helper is extended to include them so integration tests can exercise the routes end-to-end.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### `GET /api/albums/{id}/cover`
|
||||||
|
|
||||||
|
1. Parse `{id}` via `parseUUID` (the helper from `internal/api/convert.go`). Malformed UUID → 400 `bad_request` "invalid album id".
|
||||||
|
2. `q.GetAlbumByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "album not found". Other err → 500 `server_error`.
|
||||||
|
3. `resolveAlbumCoverPath(ctx, q, album)`:
|
||||||
|
- If `album.CoverArtPath != nil` and the file stats successfully, return that path.
|
||||||
|
- Otherwise, list tracks for the album (`q.ListTracksByAlbum`, which orders by disc/track number). For the first track in that ordered result, look in its directory for `cover.jpg`, `cover.jpeg`, `cover.png`, `folder.jpg`, `folder.jpeg`, `folder.png` in that order. Return the first file that exists; else return `""`.
|
||||||
|
4. Empty path return → 404 `not_found` "cover not found".
|
||||||
|
5. `os.Open` + `f.Stat`. File vanished since resolve → 404 `not_found` "cover not found". Stat err → 500.
|
||||||
|
6. Set `Content-Type` from `imageContentType(path)` (file-extension-based mapping). Call `http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)`.
|
||||||
|
|
||||||
|
### `GET /api/tracks/{id}/stream`
|
||||||
|
|
||||||
|
1. Parse `{id}`. Malformed UUID → 400 `bad_request` "invalid track id".
|
||||||
|
2. `q.GetTrackByID(ctx, id)`. `pgx.ErrNoRows` → 404 `not_found` "track not found". Other err → 500.
|
||||||
|
3. `os.Open(track.FilePath)`. File vanished → 404 `not_found` "track file not found". Other err → 500.
|
||||||
|
4. `f.Stat`. Err → 500.
|
||||||
|
5. Set `Content-Type` from `audioContentType(track.FileFormat)`. Set `Accept-Ranges: bytes`. Call `http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)`.
|
||||||
|
|
||||||
|
## Error shape
|
||||||
|
|
||||||
|
All errors use the existing `writeErr` helper → `{"error":{"code":"...","message":"..."}}`, matching the rest of `/api/*`. Codes reused from Plan 2: `bad_request`, `not_found`, `server_error`.
|
||||||
|
|
||||||
|
| Condition | Status | Code | Message |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Malformed UUID in path | 400 | `bad_request` | `invalid album id` / `invalid track id` |
|
||||||
|
| Unknown album / track | 404 | `not_found` | `album not found` / `track not found` |
|
||||||
|
| Album has no resolvable cover | 404 | `not_found` | `cover not found` |
|
||||||
|
| Track file missing on disk | 404 | `not_found` | `track file not found` |
|
||||||
|
| DB lookup failure | 500 | `server_error` | `server error` |
|
||||||
|
| `os.Stat` failure on known path | 500 | `server_error` | `server error` |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Single new file: `internal/api/media.go`. Contains both handlers and their duplicated helpers.
|
||||||
|
|
||||||
|
```
|
||||||
|
handlers
|
||||||
|
handleGetCover(w, r)
|
||||||
|
handleGetStream(w, r)
|
||||||
|
|
||||||
|
free functions (package-private)
|
||||||
|
resolveAlbumCoverPath(ctx, q, album) string
|
||||||
|
findSidecarCover(trackDir string) string
|
||||||
|
audioContentType(format string) string
|
||||||
|
imageContentType(path string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
`audioContentType` table (mirrors subsonic's `contentTypeForFormat`, duplicated verbatim):
|
||||||
|
|
||||||
|
| file_format | Content-Type |
|
||||||
|
|---|---|
|
||||||
|
| `mp3` | `audio/mpeg` |
|
||||||
|
| `flac` | `audio/flac` |
|
||||||
|
| `ogg` | `audio/ogg` |
|
||||||
|
| `opus` | `audio/ogg` |
|
||||||
|
| `m4a` | `audio/mp4` |
|
||||||
|
| `aac` | `audio/aac` |
|
||||||
|
| `wav` | `audio/wav` |
|
||||||
|
| *(other)* | `application/octet-stream` |
|
||||||
|
|
||||||
|
`imageContentType` table:
|
||||||
|
|
||||||
|
| extension (lowercase) | Content-Type |
|
||||||
|
|---|---|
|
||||||
|
| `.jpg`, `.jpeg` | `image/jpeg` |
|
||||||
|
| `.png` | `image/png` |
|
||||||
|
| `.webp` | `image/webp` |
|
||||||
|
| `.gif` | `image/gif` |
|
||||||
|
| *(other)* | `application/octet-stream` |
|
||||||
|
|
||||||
|
Sidecar lookup order (first match wins):
|
||||||
|
|
||||||
|
```
|
||||||
|
cover.jpg, cover.jpeg, cover.png,
|
||||||
|
folder.jpg, folder.jpeg, folder.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Integration tests in `internal/api/media_test.go`. They reuse the existing `testHandlers(t)`, `truncateLibrary(t, pool)`, `seedArtist`, `seedAlbum`, `seedTrack` helpers from `library_fixtures_test.go`, and add a new helper:
|
||||||
|
|
||||||
|
- `seedTrackWithFile(t, pool, albumID, artistID, title string, fileBody []byte, ext string) (dbq.Track, string)` — writes `fileBody` into `t.TempDir()` with the given extension, inserts a Track row whose `file_path` points at it, returns the track and the dir.
|
||||||
|
|
||||||
|
### Cover tests
|
||||||
|
|
||||||
|
- **Happy path (sidecar):** seed album + track, drop a known-bytes `cover.jpg` in the track's dir. GET cover → 200, `Content-Type: image/jpeg`, body equals the file bytes.
|
||||||
|
- **Happy path (explicit `cover_art_path`):** seed album with `cover_art_path` pointing at a separate image file (no sidecar). GET → 200 with the right file's bytes.
|
||||||
|
- **Explicit path missing on disk falls through to sidecar:** set `cover_art_path` to a non-existent path, drop a sidecar. GET → 200 with the sidecar bytes.
|
||||||
|
- **No art anywhere:** seed album + track, no sidecar, no `cover_art_path`. GET → 404 `not_found` "cover not found".
|
||||||
|
- **Bad UUID:** `GET /api/albums/not-a-uuid/cover` → 400 `bad_request` "invalid album id".
|
||||||
|
- **Unknown album:** `GET /api/albums/<random UUID>/cover` → 404 `not_found` "album not found".
|
||||||
|
- **Content-Type per extension:** table-driven test invoking `imageContentType` on `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, and a bogus extension.
|
||||||
|
|
||||||
|
### Stream tests
|
||||||
|
|
||||||
|
- **Happy path:** seed track with a known-bytes payload (32 KB of random-but-fixed bytes, `.mp3` extension, `file_format="mp3"`). GET → 200, `Content-Type: audio/mpeg`, `Accept-Ranges: bytes`, body equals the file bytes, `Content-Length` matches.
|
||||||
|
- **Range request:** `Range: bytes=0-99` → 206 Partial Content, body is exactly the first 100 bytes, `Content-Range: bytes 0-99/32768`.
|
||||||
|
- **Range at end:** `Range: bytes=32000-` → 206, body is the tail, `Content-Range: bytes 32000-32767/32768`.
|
||||||
|
- **If-Modified-Since pass-through:** second GET with `If-Modified-Since` set to the file's mod time → 304.
|
||||||
|
- **Bad UUID:** `GET /api/tracks/not-a-uuid/stream` → 400 `bad_request` "invalid track id".
|
||||||
|
- **Unknown track:** random UUID → 404 `not_found` "track not found".
|
||||||
|
- **Vanished file:** seed track pointing at `filepath.Join(tempDir, "does-not-exist.mp3")` → 404 `not_found` "track file not found".
|
||||||
|
- **Content-Type per format:** table-driven test invoking `audioContentType` on all seven known formats plus `"unknown"`.
|
||||||
|
|
||||||
|
### Route registration regression
|
||||||
|
|
||||||
|
Extend `TestRoutesRegisteredInMount` (from Plan 2's Task 11) to also assert the two new paths return 401 (not 404) unauthenticated:
|
||||||
|
|
||||||
|
```
|
||||||
|
"/api/albums/00000000-0000-0000-0000-000000000001/cover",
|
||||||
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||||
|
```
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Change | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `internal/api/media.go` | Create | Both handlers + locally-duplicated helpers |
|
||||||
|
| `internal/api/media_test.go` | Create | Integration tests for both endpoints, unit tests for mime helpers |
|
||||||
|
| `internal/api/library_fixtures_test.go` | Modify | Add `seedTrackWithFile` helper |
|
||||||
|
| `internal/api/api.go` | Modify | Register both new routes inside the `authed` group in `Mount` |
|
||||||
|
| `internal/api/library_test.go` | Modify | Extend `TestRoutesRegisteredInMount` to include the two new paths; extend the `newLibraryRouter` helper to register the media routes so integration tests can exercise them through the same test harness |
|
||||||
|
|
||||||
|
No migrations. No new sqlc queries (existing `GetAlbumByID`, `GetTrackByID`, `ListTracksByAlbum` are sufficient). No package-level dependencies beyond the stdlib and what `/api` already imports.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Both endpoints reachable through the production `Mount` under `RequireUser`.
|
||||||
|
- Browser `<img src="/api/albums/{id}/cover">` renders the art (or triggers `onerror` cleanly on 404).
|
||||||
|
- Browser `<audio src="/api/tracks/{id}/stream">` plays and can seek (verifies Range).
|
||||||
|
- Integration test suite green, no flakes.
|
||||||
|
- Manual smoke: log in, curl each endpoint, verify returns + Range behavior.
|
||||||
|
- PR merges to `main` via the existing `dev` → `main` flow.
|
||||||
@@ -24,6 +24,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
|
|||||||
authed.Use(auth.RequireUser(pool))
|
authed.Use(auth.RequireUser(pool))
|
||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
|
|
||||||
|
authed.Get("/artists", h.handleListArtists)
|
||||||
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||||
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||||
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
|
authed.Get("/tracks/{id}/stream", h.handleGetStream)
|
||||||
|
authed.Get("/search", h.handleSearch)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errBadPaging = errors.New("invalid limit or offset")
|
||||||
|
|
||||||
|
// uuidToString renders a pgtype.UUID as a canonical hyphenated string.
|
||||||
|
// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic
|
||||||
|
// package intentionally — the two packages must not depend on each other.
|
||||||
|
func uuidToString(u pgtype.UUID) string {
|
||||||
|
if !u.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b := u.Bytes
|
||||||
|
const hex = "0123456789abcdef"
|
||||||
|
out := make([]byte, 36)
|
||||||
|
j := 0
|
||||||
|
for i, x := range b {
|
||||||
|
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||||
|
out[j] = '-'
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out[j] = hex[x>>4]
|
||||||
|
out[j+1] = hex[x&0x0f]
|
||||||
|
j += 2
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUUID accepts the canonical hyphenated form produced by uuidToString.
|
||||||
|
// Returns ok=false on any malformed input so handlers can 400 cleanly.
|
||||||
|
func parseUUID(s string) (pgtype.UUID, bool) {
|
||||||
|
var u pgtype.UUID
|
||||||
|
if err := u.Scan(s); err != nil {
|
||||||
|
return pgtype.UUID{}, false
|
||||||
|
}
|
||||||
|
return u, u.Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the
|
||||||
|
// date is unset. Album release years are optional at scan time.
|
||||||
|
func yearFromDate(d pgtype.Date) int {
|
||||||
|
if !d.Valid {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d.Time.Year()
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms)
|
||||||
|
// to seconds, rounding to nearest. Callers display seconds; sub-second precision
|
||||||
|
// is not useful in UI.
|
||||||
|
func durationMsToSec(ms int32) int {
|
||||||
|
return int((ms + 500) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverURL returns the /api relative URL for an album's cover art. The
|
||||||
|
// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire
|
||||||
|
// <img src> to it.
|
||||||
|
func coverURL(albumID pgtype.UUID) string {
|
||||||
|
return "/api/albums/" + uuidToString(albumID) + "/cover"
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamURL returns the /api relative URL for a track's audio stream. The
|
||||||
|
// endpoint ships in Plan 3.
|
||||||
|
func streamURL(trackID pgtype.UUID) string {
|
||||||
|
return "/api/tracks/" + uuidToString(trackID) + "/stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistRefFrom projects a dbq.Artist into an ArtistRef. albumCount must be
|
||||||
|
// pre-computed by the caller (one query per artist in lists).
|
||||||
|
func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef {
|
||||||
|
return ArtistRef{
|
||||||
|
ID: uuidToString(a.ID),
|
||||||
|
Name: a.Name,
|
||||||
|
AlbumCount: albumCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be
|
||||||
|
// pre-resolved because Album only carries artist_id. trackCount and
|
||||||
|
// durationSec may be 0 when the caller does not compute them.
|
||||||
|
func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef {
|
||||||
|
return AlbumRef{
|
||||||
|
ID: uuidToString(a.ID),
|
||||||
|
Title: a.Title,
|
||||||
|
ArtistID: uuidToString(a.ArtistID),
|
||||||
|
ArtistName: artistName,
|
||||||
|
Year: yearFromDate(a.ReleaseDate),
|
||||||
|
TrackCount: trackCount,
|
||||||
|
DurationSec: durationSec,
|
||||||
|
CoverURL: coverURL(a.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRefFrom projects a dbq.Track into a TrackRef. Parent names must be
|
||||||
|
// pre-resolved because Track only carries album_id and artist_id.
|
||||||
|
func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef {
|
||||||
|
ref := TrackRef{
|
||||||
|
ID: uuidToString(t.ID),
|
||||||
|
Title: t.Title,
|
||||||
|
AlbumID: uuidToString(t.AlbumID),
|
||||||
|
AlbumTitle: albumTitle,
|
||||||
|
ArtistID: uuidToString(t.ArtistID),
|
||||||
|
ArtistName: artistName,
|
||||||
|
DurationSec: durationMsToSec(t.DurationMs),
|
||||||
|
StreamURL: streamURL(t.ID),
|
||||||
|
}
|
||||||
|
if t.TrackNumber != nil {
|
||||||
|
ref.TrackNumber = int(*t.TrackNumber)
|
||||||
|
}
|
||||||
|
if t.DiscNumber != nil {
|
||||||
|
ref.DiscNumber = int(*t.DiscNumber)
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePaging reads limit/offset from the query string, applying defaults
|
||||||
|
// and clamping. Returns a 400-worthy error when the values are non-numeric;
|
||||||
|
// out-of-range values silently clamp (deliberate — UX).
|
||||||
|
func parsePaging(raw url.Values) (limit, offset int, err error) {
|
||||||
|
const (
|
||||||
|
defLimit = 50
|
||||||
|
maxLimit = 200
|
||||||
|
)
|
||||||
|
limit = defLimit
|
||||||
|
if s := strings.TrimSpace(raw.Get("limit")); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, errBadPaging
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > maxLimit {
|
||||||
|
n = maxLimit
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(raw.Get("offset")); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, errBadPaging
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
return limit, offset, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUUIDToString(t *testing.T) {
|
||||||
|
var u pgtype.UUID
|
||||||
|
if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
got := uuidToString(u)
|
||||||
|
want := "00112233-4455-6677-8899-aabbccddeeff"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("uuidToString = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var zero pgtype.UUID
|
||||||
|
if s := uuidToString(zero); s != "" {
|
||||||
|
t.Errorf("uuidToString(invalid) = %q, want empty", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUUID(t *testing.T) {
|
||||||
|
u, ok := parseUUID("00112233-4455-6677-8899-aabbccddeeff")
|
||||||
|
if !ok || !u.Valid {
|
||||||
|
t.Fatalf("parseUUID valid: ok=%v valid=%v", ok, u.Valid)
|
||||||
|
}
|
||||||
|
if _, ok := parseUUID("not-a-uuid"); ok {
|
||||||
|
t.Error("parseUUID accepted garbage")
|
||||||
|
}
|
||||||
|
if _, ok := parseUUID(""); ok {
|
||||||
|
t.Error("parseUUID accepted empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYearFromDate(t *testing.T) {
|
||||||
|
var d pgtype.Date
|
||||||
|
if y := yearFromDate(d); y != 0 {
|
||||||
|
t.Errorf("yearFromDate(invalid) = %d, want 0", y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurationMsToSec(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ms int32
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 0},
|
||||||
|
{500, 1}, // rounds up
|
||||||
|
{499, 0}, // rounds down
|
||||||
|
{1500, 2}, // rounds up
|
||||||
|
{60000, 60},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := durationMsToSec(c.ms); got != c.want {
|
||||||
|
t.Errorf("durationMsToSec(%d) = %d, want %d", c.ms, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverURLAndStreamURL(t *testing.T) {
|
||||||
|
var u pgtype.UUID
|
||||||
|
if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
if got := coverURL(u); got != "/api/albums/00112233-4455-6677-8899-aabbccddeeff/cover" {
|
||||||
|
t.Errorf("coverURL = %q", got)
|
||||||
|
}
|
||||||
|
if got := streamURL(u); got != "/api/tracks/00112233-4455-6677-8899-aabbccddeeff/stream" {
|
||||||
|
t.Errorf("streamURL = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtistRefFrom(t *testing.T) {
|
||||||
|
var id pgtype.UUID
|
||||||
|
_ = id.Scan("00112233-4455-6677-8899-aabbccddeeff")
|
||||||
|
a := dbq.Artist{ID: id, Name: "Beatles", SortName: "Beatles"}
|
||||||
|
got := artistRefFrom(a, 7)
|
||||||
|
if got.ID != "00112233-4455-6677-8899-aabbccddeeff" {
|
||||||
|
t.Errorf("ID = %q", got.ID)
|
||||||
|
}
|
||||||
|
if got.Name != "Beatles" || got.AlbumCount != 7 {
|
||||||
|
t.Errorf("ref = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbumRefFrom(t *testing.T) {
|
||||||
|
var aid, artID pgtype.UUID
|
||||||
|
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||||||
|
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||||||
|
var rel pgtype.Date
|
||||||
|
_ = rel.Scan("1969-09-26")
|
||||||
|
a := dbq.Album{ID: aid, Title: "Abbey Road", ArtistID: artID, ReleaseDate: rel}
|
||||||
|
got := albumRefFrom(a, "Beatles", 17, 2820)
|
||||||
|
if got.Title != "Abbey Road" || got.ArtistName != "Beatles" {
|
||||||
|
t.Errorf("ref = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TrackCount != 17 || got.DurationSec != 2820 {
|
||||||
|
t.Errorf("counts = %+v", got)
|
||||||
|
}
|
||||||
|
if got.Year != 1969 {
|
||||||
|
t.Errorf("year = %d", got.Year)
|
||||||
|
}
|
||||||
|
if got.CoverURL != "/api/albums/00000000-0000-0000-0000-000000000001/cover" {
|
||||||
|
t.Errorf("cover_url = %q", got.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackRefFrom(t *testing.T) {
|
||||||
|
var tid, aid, artID pgtype.UUID
|
||||||
|
_ = tid.Scan("00000000-0000-0000-0000-000000000010")
|
||||||
|
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||||||
|
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||||||
|
trackNum := int32(3)
|
||||||
|
discNum := int32(1)
|
||||||
|
t2 := dbq.Track{
|
||||||
|
ID: tid, Title: "Something", AlbumID: aid, ArtistID: artID,
|
||||||
|
TrackNumber: &trackNum, DiscNumber: &discNum,
|
||||||
|
DurationMs: 183_000,
|
||||||
|
}
|
||||||
|
got := trackRefFrom(t2, "Abbey Road", "Beatles")
|
||||||
|
if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
|
||||||
|
t.Errorf("ref = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TrackNumber != 3 || got.DiscNumber != 1 {
|
||||||
|
t.Errorf("positions = %+v", got)
|
||||||
|
}
|
||||||
|
if got.DurationSec != 183 {
|
||||||
|
t.Errorf("duration = %d, want 183", got.DurationSec)
|
||||||
|
}
|
||||||
|
if got.StreamURL != "/api/tracks/00000000-0000-0000-0000-000000000010/stream" {
|
||||||
|
t.Errorf("stream_url = %q", got.StreamURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackRefFromNilPositions(t *testing.T) {
|
||||||
|
var tid, aid, artID pgtype.UUID
|
||||||
|
_ = tid.Scan("00000000-0000-0000-0000-000000000010")
|
||||||
|
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||||||
|
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||||||
|
t2 := dbq.Track{ID: tid, AlbumID: aid, ArtistID: artID, DurationMs: 1000}
|
||||||
|
got := trackRefFrom(t2, "A", "B")
|
||||||
|
if got.TrackNumber != 0 || got.DiscNumber != 0 {
|
||||||
|
t.Errorf("nil positions should be 0, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePaging(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query url.Values
|
||||||
|
wantLimit int
|
||||||
|
wantOffset int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default (empty values)",
|
||||||
|
query: url.Values{},
|
||||||
|
wantLimit: 50,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit=0 clamps to 1",
|
||||||
|
query: url.Values{"limit": {"0"}},
|
||||||
|
wantLimit: 1,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit=500 clamps to 200",
|
||||||
|
query: url.Values{"limit": {"500"}},
|
||||||
|
wantLimit: 200,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "offset=-5 clamps to 0",
|
||||||
|
query: url.Values{"offset": {"-5"}},
|
||||||
|
wantLimit: 50,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit=abc errors",
|
||||||
|
query: url.Values{"limit": {"abc"}},
|
||||||
|
wantLimit: 0,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "offset=xyz errors",
|
||||||
|
query: url.Values{"offset": {"xyz"}},
|
||||||
|
wantLimit: 0,
|
||||||
|
wantOffset: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normal values",
|
||||||
|
query: url.Values{"limit": {"25"}, "offset": {"100"}},
|
||||||
|
wantLimit: 25,
|
||||||
|
wantOffset: 100,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotLimit, gotOffset, err := parsePaging(tt.query)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parsePaging() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gotLimit != tt.wantLimit {
|
||||||
|
t.Errorf("parsePaging() limit = %d, want %d", gotLimit, tt.wantLimit)
|
||||||
|
}
|
||||||
|
if gotOffset != tt.wantOffset {
|
||||||
|
t.Errorf("parsePaging() offset = %d, want %d", gotOffset, tt.wantOffset)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,12 @@ func writeErr(w http.ResponseWriter, status int, code, message string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
|
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeJSON emits a 2xx response with the given body JSON-encoded. Mirrors
|
||||||
|
// writeErr's shape so handlers have one shape for success and one for
|
||||||
|
// failure.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album +
|
||||||
|
// artist names so the response is self-contained — clients should not need
|
||||||
|
// a follow-up request to render a track outside an album view.
|
||||||
|
func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
track, err := q.GetTrackByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get track failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: get track album failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: get track artist failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
||||||
|
// tracks (ordered by disc/track number via the underlying query) with
|
||||||
|
// duration summed from the track list — keeps one source of truth.
|
||||||
|
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
album, err := q.GetAlbumByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get album failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: get album artist failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tracks, err := q.ListTracksByAlbum(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list tracks failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refs := make([]TrackRef, 0, len(tracks))
|
||||||
|
durSec := 0
|
||||||
|
for _, t := range tracks {
|
||||||
|
ref := trackRefFrom(t, album.Title, artist.Name)
|
||||||
|
refs = append(refs, ref)
|
||||||
|
durSec += ref.DurationSec
|
||||||
|
}
|
||||||
|
detail := AlbumDetail{
|
||||||
|
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
|
||||||
|
Tracks: refs,
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
||||||
|
// each album carries its own track_count (one count query per album, same
|
||||||
|
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
|
||||||
|
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
artist, err := q.GetArtistByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "artist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get artist failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, err := q.ListAlbumsByArtist(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list albums by artist failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refs := make([]AlbumRef, 0, len(albums))
|
||||||
|
for _, a := range albums {
|
||||||
|
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
|
||||||
|
if cerr != nil {
|
||||||
|
h.logger.Error("api: count tracks failed", "err", cerr)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||||
|
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
|
||||||
|
}
|
||||||
|
detail := ArtistDetail{
|
||||||
|
ArtistRef: artistRefFrom(artist, len(albums)),
|
||||||
|
Albums: refs,
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListArtists implements GET /api/artists with two sort modes
|
||||||
|
// (alpha|newest) and enveloped pagination. album_count per artist is
|
||||||
|
// computed via an N+1 — acceptable at limit=200.
|
||||||
|
func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sort := r.URL.Query().Get("sort")
|
||||||
|
if sort == "" {
|
||||||
|
sort = "alpha"
|
||||||
|
}
|
||||||
|
if sort != "alpha" && sort != "newest" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "sort must be alpha or newest")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
var artists []dbq.Artist
|
||||||
|
switch sort {
|
||||||
|
case "newest":
|
||||||
|
artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{
|
||||||
|
Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
default: // alpha
|
||||||
|
artists, err = q.ListArtistsAlpha(r.Context(), dbq.ListArtistsAlphaParams{
|
||||||
|
Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list artists failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := q.CountArtists(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: count artists failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ArtistRef, 0, len(artists))
|
||||||
|
for _, a := range artists {
|
||||||
|
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
||||||
|
if aerr != nil {
|
||||||
|
h.logger.Error("api: list albums for artist failed", "err", aerr)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items = append(items, artistRefFrom(a, len(albums)))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, Page[ArtistRef]{
|
||||||
|
Items: items,
|
||||||
|
Total: int(total),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// truncateLibrary clears the music tables between tests. Sessions/users are
|
||||||
|
// cleared by testHandlers. CASCADE covers tracks→albums→artists FK chain.
|
||||||
|
func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||||
|
t.Fatalf("truncate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedArtist inserts an artist with deterministic sort_name = name.
|
||||||
|
func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist {
|
||||||
|
t.Helper()
|
||||||
|
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||||
|
Name: name,
|
||||||
|
SortName: name,
|
||||||
|
Mbid: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertArtist(%s): %v", name, err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAlbum inserts an album belonging to artistID. releaseYear=0 leaves
|
||||||
|
// release_date unset.
|
||||||
|
func seedAlbum(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string, releaseYear int) dbq.Album {
|
||||||
|
t.Helper()
|
||||||
|
var rel pgtype.Date
|
||||||
|
if releaseYear > 0 {
|
||||||
|
rel = pgtype.Date{Time: time.Date(releaseYear, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||||
|
}
|
||||||
|
a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||||
|
Title: title,
|
||||||
|
SortTitle: title,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ReleaseDate: rel,
|
||||||
|
Mbid: nil,
|
||||||
|
CoverArtPath: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertAlbum(%s): %v", title, err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTrack inserts a track. trackNumber<=0 leaves it NULL. filePath is
|
||||||
|
// synthesized from title to stay unique across seeds.
|
||||||
|
func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
var tn *int32
|
||||||
|
if trackNumber > 0 {
|
||||||
|
v := int32(trackNumber)
|
||||||
|
tn = &v
|
||||||
|
}
|
||||||
|
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: title,
|
||||||
|
AlbumID: albumID,
|
||||||
|
ArtistID: artistID,
|
||||||
|
TrackNumber: tn,
|
||||||
|
DiscNumber: nil,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
FilePath: "/seed/" + title + ".flac",
|
||||||
|
FileSize: 1024,
|
||||||
|
FileFormat: "flac",
|
||||||
|
Bitrate: nil,
|
||||||
|
Mbid: nil,
|
||||||
|
Genre: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertTrack(%s): %v", title, err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTrackWithFile creates a fresh temp directory, writes fileBody to
|
||||||
|
// <dir>/<title>.<ext>, and inserts a Track row whose file_path points at it.
|
||||||
|
// Returns the inserted Track and the directory (callers drop sidecar covers
|
||||||
|
// into this dir when a test needs them). FileFormat is ext lowercased.
|
||||||
|
func seedTrackWithFile(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, fileBody []byte, ext string) (dbq.Track, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, title+"."+ext)
|
||||||
|
if err := os.WriteFile(path, fileBody, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
one := int32(1)
|
||||||
|
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: title,
|
||||||
|
AlbumID: albumID,
|
||||||
|
ArtistID: artistID,
|
||||||
|
TrackNumber: &one,
|
||||||
|
DiscNumber: nil,
|
||||||
|
// DurationMs: arbitrary non-zero; tests don't assert on it.
|
||||||
|
DurationMs: int32(len(fileBody)),
|
||||||
|
FilePath: path,
|
||||||
|
FileSize: int64(len(fileBody)),
|
||||||
|
FileFormat: strings.ToLower(ext),
|
||||||
|
Bitrate: nil,
|
||||||
|
Mbid: nil,
|
||||||
|
Genre: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertTrack(%s): %v", title, err)
|
||||||
|
}
|
||||||
|
return tr, dir
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLibraryRouter builds a test-only chi router with the library handlers
|
||||||
|
// mounted at their path-style routes. Tests hit this router rather than
|
||||||
|
// calling handler methods directly so chi URL params populate correctly.
|
||||||
|
// RequireUser is NOT applied — library handlers don't read user context.
|
||||||
|
func newLibraryRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/artists", h.handleListArtists)
|
||||||
|
r.Get("/api/artists/{id}", h.handleGetArtist)
|
||||||
|
r.Get("/api/albums/{id}", h.handleGetAlbum)
|
||||||
|
r.Get("/api/albums/{id}/cover", h.handleGetCover)
|
||||||
|
r.Get("/api/tracks/{id}", h.handleGetTrack)
|
||||||
|
r.Get("/api/tracks/{id}/stream", h.handleGetStream)
|
||||||
|
r.Get("/api/search", h.handleSearch)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTrack_HappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Beatles")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||||||
|
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got TrackRef
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
|
||||||
|
t.Errorf("ref = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TrackNumber != 3 || got.DurationSec != 183 {
|
||||||
|
t.Errorf("positions = %+v", got)
|
||||||
|
}
|
||||||
|
want := "/api/tracks/" + uuidToString(track.ID) + "/stream"
|
||||||
|
if got.StreamURL != want {
|
||||||
|
t.Errorf("stream_url = %q, want %q", got.StreamURL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTrack_BadUUID(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetTrack_NotFound(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/tracks/00000000-0000-0000-0000-000000000001", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetAlbum_HappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Beatles")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||||||
|
seedTrack(t, pool, album.ID, artist.ID, "Come Together", 1, 259_000)
|
||||||
|
seedTrack(t, pool, album.ID, artist.ID, "Something", 2, 183_000)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got AlbumDetail
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if got.Title != "Abbey Road" || got.ArtistName != "Beatles" || got.Year != 1969 {
|
||||||
|
t.Errorf("album = %+v", got)
|
||||||
|
}
|
||||||
|
if len(got.Tracks) != 2 {
|
||||||
|
t.Fatalf("tracks len = %d, want 2", len(got.Tracks))
|
||||||
|
}
|
||||||
|
if got.Tracks[0].Title != "Come Together" || got.Tracks[1].Title != "Something" {
|
||||||
|
t.Errorf("tracks = %+v", got.Tracks)
|
||||||
|
}
|
||||||
|
if got.TrackCount != 2 || got.DurationSec != 442 { // 259 + 183
|
||||||
|
t.Errorf("counts = track=%d duration=%d", got.TrackCount, got.DurationSec)
|
||||||
|
}
|
||||||
|
want := "/api/albums/" + uuidToString(album.ID) + "/cover"
|
||||||
|
if got.CoverURL != want {
|
||||||
|
t.Errorf("cover_url = %q", got.CoverURL)
|
||||||
|
}
|
||||||
|
// StreamURL on nested tracks must be set
|
||||||
|
if got.Tracks[0].StreamURL == "" {
|
||||||
|
t.Error("nested track missing stream_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetAlbum_NoTracks(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Beatles")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Empty", 0)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got AlbumDetail
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Tracks == nil {
|
||||||
|
t.Error("tracks = nil, want [] for JSON encoding")
|
||||||
|
}
|
||||||
|
if len(got.Tracks) != 0 {
|
||||||
|
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
|
||||||
|
}
|
||||||
|
// Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON
|
||||||
|
if got.Year != 0 {
|
||||||
|
t.Errorf("year = %d, want 0", got.Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetAlbum_BadUUID(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetAlbum_NotFound(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/albums/00000000-0000-0000-0000-000000000001", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetArtist_HappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Beatles")
|
||||||
|
album1 := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||||||
|
album2 := seedAlbum(t, pool, artist.ID, "Let It Be", 1970)
|
||||||
|
seedTrack(t, pool, album1.ID, artist.ID, "Something", 3, 183_000)
|
||||||
|
seedTrack(t, pool, album2.ID, artist.ID, "Get Back", 1, 189_000)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got ArtistDetail
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if got.Name != "Beatles" {
|
||||||
|
t.Errorf("name = %q", got.Name)
|
||||||
|
}
|
||||||
|
if got.AlbumCount != 2 {
|
||||||
|
t.Errorf("album_count = %d, want 2", got.AlbumCount)
|
||||||
|
}
|
||||||
|
if len(got.Albums) != 2 {
|
||||||
|
t.Fatalf("albums len = %d, want 2", len(got.Albums))
|
||||||
|
}
|
||||||
|
// ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first.
|
||||||
|
if got.Albums[0].Title != "Abbey Road" {
|
||||||
|
t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title)
|
||||||
|
}
|
||||||
|
for _, a := range got.Albums {
|
||||||
|
if a.ArtistName != "Beatles" {
|
||||||
|
t.Errorf("album.artist_name = %q", a.ArtistName)
|
||||||
|
}
|
||||||
|
if a.CoverURL == "" {
|
||||||
|
t.Error("album missing cover_url")
|
||||||
|
}
|
||||||
|
if a.TrackCount != 1 {
|
||||||
|
t.Errorf("album.track_count = %d, want 1", a.TrackCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetArtist_NoAlbums(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Solo")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got ArtistDetail
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Albums == nil {
|
||||||
|
t.Error("albums = nil, want []")
|
||||||
|
}
|
||||||
|
if got.AlbumCount != 0 {
|
||||||
|
t.Errorf("album_count = %d, want 0", got.AlbumCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetArtist_BadUUID(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists/not-a-uuid", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetArtist_NotFound(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/artists/00000000-0000-0000-0000-000000000001", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_DefaultAlpha(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
seedArtist(t, pool, "Beatles")
|
||||||
|
seedArtist(t, pool, "ABBA")
|
||||||
|
seedArtist(t, pool, "Zeppelin")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.Total != 3 {
|
||||||
|
t.Errorf("total = %d, want 3", got.Total)
|
||||||
|
}
|
||||||
|
if got.Limit != 50 || got.Offset != 0 {
|
||||||
|
t.Errorf("pagination defaults = limit=%d offset=%d, want 50/0", got.Limit, got.Offset)
|
||||||
|
}
|
||||||
|
if len(got.Items) != 3 {
|
||||||
|
t.Fatalf("items len = %d", len(got.Items))
|
||||||
|
}
|
||||||
|
if got.Items[0].Name != "ABBA" || got.Items[2].Name != "Zeppelin" {
|
||||||
|
t.Errorf("alpha order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_SortNewest(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
// Seeded in this order; created_at DESC means Third first.
|
||||||
|
seedArtist(t, pool, "First")
|
||||||
|
seedArtist(t, pool, "Second")
|
||||||
|
seedArtist(t, pool, "Third")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=newest", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if len(got.Items) != 3 {
|
||||||
|
t.Fatalf("items len = %d", len(got.Items))
|
||||||
|
}
|
||||||
|
if got.Items[0].Name != "Third" || got.Items[2].Name != "First" {
|
||||||
|
t.Errorf("newest order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_InvalidSort(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
seedArtist(t, pool, "Only")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=garbage", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_Pagination(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
for _, n := range []string{"A", "B", "C", "D", "E"} {
|
||||||
|
seedArtist(t, pool, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=2&offset=2", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Total != 5 {
|
||||||
|
t.Errorf("total = %d, want 5", got.Total)
|
||||||
|
}
|
||||||
|
if got.Limit != 2 || got.Offset != 2 {
|
||||||
|
t.Errorf("page = limit=%d offset=%d", got.Limit, got.Offset)
|
||||||
|
}
|
||||||
|
if len(got.Items) != 2 {
|
||||||
|
t.Fatalf("items len = %d", len(got.Items))
|
||||||
|
}
|
||||||
|
if got.Items[0].Name != "C" || got.Items[1].Name != "D" {
|
||||||
|
t.Errorf("slice = %q, %q", got.Items[0].Name, got.Items[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_LimitClamped(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
seedArtist(t, pool, "One")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=9999", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Limit != 200 {
|
||||||
|
t.Errorf("limit = %d, want 200 (max clamp)", got.Limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_LimitNonNumeric(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=abc", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_AlbumCount(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
seedAlbum(t, pool, artist.ID, "Y", 0)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if len(got.Items) != 1 || got.Items[0].AlbumCount != 2 {
|
||||||
|
t.Errorf("items = %+v", got.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListArtists_EmptyDB(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got Page[ArtistRef]
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Items == nil {
|
||||||
|
t.Error("items = nil, want []")
|
||||||
|
}
|
||||||
|
if got.Total != 0 {
|
||||||
|
t.Errorf("total = %d", got.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoutesRegisteredInMount verifies the production Mount() wires every
|
||||||
|
// library route. We hit each path through the real Mount (no RequireUser
|
||||||
|
// stripped — we expect 401 from the middleware, which is fine — that
|
||||||
|
// proves the route reached the authenticated group).
|
||||||
|
func TestRoutesRegisteredInMount(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
Mount(r, h.pool, h.logger)
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
"/api/artists",
|
||||||
|
"/api/artists/00000000-0000-0000-0000-000000000001",
|
||||||
|
"/api/albums/00000000-0000-0000-0000-000000000001",
|
||||||
|
"/api/albums/00000000-0000-0000-0000-000000000001/cover",
|
||||||
|
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
||||||
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||||
|
"/api/search?q=x",
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
// Unauthenticated → 401. What we must NOT see is 404 (route missing).
|
||||||
|
if w.Code == http.StatusNotFound {
|
||||||
|
t.Errorf("path %s returned 404 — route not registered", p)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("path %s status = %d, want 401 (unauth)", p, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Package api — media endpoints serve raw bytes (cover art, audio streams).
|
||||||
|
// The helpers below duplicate shape with internal/subsonic/stream.go by
|
||||||
|
// design; the two packages are kept independent so subsonic can freeze as a
|
||||||
|
// compatibility surface while /api evolves. See project memory
|
||||||
|
// `project_subsonic_legacy.md` for the rationale.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sidecarNames is the lookup order for cover art living next to audio files.
|
||||||
|
// Matches common library conventions: cover.* wins over folder.*; JPEG wins
|
||||||
|
// over PNG when both exist.
|
||||||
|
var sidecarNames = []string{
|
||||||
|
"cover.jpg", "cover.jpeg", "cover.png",
|
||||||
|
"folder.jpg", "folder.jpeg", "folder.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSidecarCover looks for a conventional cover image in the directory that
|
||||||
|
// contains trackPath. Returns "" if no sidecar exists.
|
||||||
|
func findSidecarCover(trackPath string) string {
|
||||||
|
dir := filepath.Dir(trackPath)
|
||||||
|
for _, name := range sidecarNames {
|
||||||
|
candidate := filepath.Join(dir, name)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
|
||||||
|
// It prefers an explicit cover_art_path (set by the scanner in a future
|
||||||
|
// milestone) and falls back to a sidecar next to the first track in the
|
||||||
|
// album's directory. "" means no art was found.
|
||||||
|
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||||
|
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||||
|
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||||
|
return *album.CoverArtPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracks, err := q.ListTracksByAlbum(ctx, album.ID)
|
||||||
|
if err != nil || len(tracks) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return findSidecarCover(tracks[0].FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioContentType maps the short file_format recorded on tracks (mp3, flac,
|
||||||
|
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
|
||||||
|
// Unknown formats fall back to octet-stream so the browser downloads them
|
||||||
|
// rather than attempting to decode.
|
||||||
|
//
|
||||||
|
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
|
||||||
|
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
|
||||||
|
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
|
||||||
|
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
|
||||||
|
// (we don't record that format). Don't "fix" these to match subsonic.
|
||||||
|
func audioContentType(format string) string {
|
||||||
|
switch strings.ToLower(format) {
|
||||||
|
case "mp3":
|
||||||
|
return "audio/mpeg"
|
||||||
|
case "flac":
|
||||||
|
return "audio/flac"
|
||||||
|
case "ogg", "opus":
|
||||||
|
return "audio/ogg"
|
||||||
|
case "m4a":
|
||||||
|
return "audio/mp4"
|
||||||
|
case "aac":
|
||||||
|
return "audio/aac"
|
||||||
|
case "wav":
|
||||||
|
return "audio/wav"
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageContentType maps a file extension to a MIME type for cover art.
|
||||||
|
// Unknown extensions fall back to octet-stream.
|
||||||
|
func imageContentType(path string) string {
|
||||||
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover
|
||||||
|
// path (explicit column, else sidecar next to first track), then delegates
|
||||||
|
// byte-serving to http.ServeContent so clients get Range / If-Modified-Since
|
||||||
|
// / ETag for free.
|
||||||
|
func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
album, err := q.GetAlbumByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get album for cover failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := resolveAlbumCoverPath(r.Context(), q, album)
|
||||||
|
if path == "" {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "cover not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
// resolveAlbumCoverPath saw the file a moment ago; if it vanished
|
||||||
|
// between stat and open, treat it the same as no-art — the user
|
||||||
|
// experience (404) matches, and 500 would misleadingly imply a server
|
||||||
|
// bug rather than a filesystem race.
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "cover not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: stat cover failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", imageContentType(path))
|
||||||
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
|
||||||
|
// disk and delegates byte-serving to http.ServeContent, which handles Range,
|
||||||
|
// If-Modified-Since, and ETag based on the file's mod time.
|
||||||
|
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
track, err := q.GetTrackByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("api: get track for stream failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(track.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
// File vanished (scanner indexed it, filesystem lost it). Treat as
|
||||||
|
// 404 so clients can fall back to the next item in a queue.
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "track file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: stat track failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAudioContentType(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"MP3": "audio/mpeg",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"unknown": "application/octet-stream",
|
||||||
|
"": "application/octet-stream",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := audioContentType(in); got != want {
|
||||||
|
t.Errorf("audioContentType(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageContentType(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"/a/cover.jpg": "image/jpeg",
|
||||||
|
"/a/cover.JPG": "image/jpeg",
|
||||||
|
"/a/cover.jpeg": "image/jpeg",
|
||||||
|
"/a/cover.png": "image/png",
|
||||||
|
"/a/cover.webp": "image/webp",
|
||||||
|
"/a/cover.gif": "image/gif",
|
||||||
|
"/a/cover.bmp": "application/octet-stream",
|
||||||
|
"/a/cover": "application/octet-stream",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := imageContentType(in); got != want {
|
||||||
|
t.Errorf("imageContentType(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := findSidecarCover(filepath.Join(dir, "any-track.flac"))
|
||||||
|
if got != filepath.Join(dir, "cover.jpg") {
|
||||||
|
t.Errorf("got %q, want cover.jpg path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSidecarCover_FallsBackToFolder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := findSidecarCover(filepath.Join(dir, "t.flac"))
|
||||||
|
if got != filepath.Join(dir, "folder.png") {
|
||||||
|
t.Errorf("got %q, want folder.png path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSidecarCover_NoneFound(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if got := findSidecarCover(filepath.Join(dir, "t.flac")); got != "" {
|
||||||
|
t.Errorf("got %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) {
|
||||||
|
_, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
explicit := filepath.Join(dir, "explicit.jpg")
|
||||||
|
if err := os.WriteFile(explicit, []byte("e"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
|
||||||
|
t.Fatalf("UPDATE albums: %v", err)
|
||||||
|
}
|
||||||
|
got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
|
||||||
|
if got != explicit {
|
||||||
|
t.Errorf("got %q, want %q", got, explicit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
|
||||||
|
_, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
|
||||||
|
_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
|
||||||
|
sidecar := filepath.Join(dir, "cover.jpg")
|
||||||
|
if err := os.WriteFile(sidecar, []byte("s"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
|
||||||
|
filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
|
||||||
|
t.Fatalf("UPDATE albums: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
|
||||||
|
if got != sidecar {
|
||||||
|
t.Errorf("got %q, want %q", got, sidecar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAlbumCoverPath_NoArt(t *testing.T) {
|
||||||
|
_, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000)
|
||||||
|
got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album)
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("got %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAlbum reloads the album row so tests see updates made via raw SQL.
|
||||||
|
func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album {
|
||||||
|
t.Helper()
|
||||||
|
a, err := dbq.New(pool).GetAlbumByID(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumByID: %v", err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_SidecarHappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
|
||||||
|
_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
|
||||||
|
coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(w.Body.Bytes(), coverBytes) {
|
||||||
|
t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
explicit := filepath.Join(dir, "art.png")
|
||||||
|
pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic
|
||||||
|
if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(w.Body.Bytes(), pngBytes) {
|
||||||
|
t.Errorf("body mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
|
||||||
|
_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
|
||||||
|
sidecarBytes := []byte("sidecar")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
|
||||||
|
filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(w.Body.Bytes(), sidecarBytes) {
|
||||||
|
t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_NoArt(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
if body.Error.Code != "not_found" || body.Error.Message != "cover not found" {
|
||||||
|
t.Errorf("error body = %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_BadUUID(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCover_UnknownAlbum(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/albums/00000000-0000-0000-0000-000000000001/cover", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
if body.Error.Message != "album not found" {
|
||||||
|
t.Errorf("message = %q, want \"album not found\"", body.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deterministicBytes returns n bytes whose values cycle 0..255. Using a
|
||||||
|
// fixed pattern (not crypto/rand) makes assertions easy and the test
|
||||||
|
// reproducible.
|
||||||
|
func deterministicBytes(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_HappyPath(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
body := deterministicBytes(32 * 1024)
|
||||||
|
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" {
|
||||||
|
t.Errorf("Accept-Ranges = %q", ar)
|
||||||
|
}
|
||||||
|
if cl := w.Header().Get("Content-Length"); cl != "32768" {
|
||||||
|
t.Errorf("Content-Length = %q, want 32768", cl)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(w.Body.Bytes(), body) {
|
||||||
|
t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_RangeFromStart(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
body := deterministicBytes(32 * 1024)
|
||||||
|
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
|
||||||
|
req.Header.Set("Range", "bytes=0-99")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d, want 206", w.Code)
|
||||||
|
}
|
||||||
|
if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" {
|
||||||
|
t.Errorf("Content-Range = %q", cr)
|
||||||
|
}
|
||||||
|
if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) {
|
||||||
|
t.Errorf("body mismatch: len=%d, want 100", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
body := deterministicBytes(32 * 1024)
|
||||||
|
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
|
||||||
|
req.Header.Set("Range", "bytes=32000-")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" {
|
||||||
|
t.Errorf("Content-Range = %q", cr)
|
||||||
|
}
|
||||||
|
if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) {
|
||||||
|
t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
body := deterministicBytes(1024)
|
||||||
|
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
|
||||||
|
|
||||||
|
info, err := os.Stat(track.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
|
||||||
|
req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotModified {
|
||||||
|
t.Fatalf("status = %d, want 304", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Body.Len(); got != 0 {
|
||||||
|
t.Errorf("body len = %d, want 0 for 304", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_BadUUID(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_UnknownTrack(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
if body.Error.Message != "track not found" {
|
||||||
|
t.Errorf("message = %q, want \"track not found\"", body.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStream_VanishedFile(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "A")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "X", 0)
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Insert a track whose file_path points somewhere we never create.
|
||||||
|
gone := filepath.Join(dir, "does-not-exist.mp3")
|
||||||
|
if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: "gone", AlbumID: album.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Look up the just-inserted track so we can address it by id.
|
||||||
|
tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
if body.Error.Message != "track file not found" {
|
||||||
|
t.Errorf("message = %q, want \"track file not found\"", body.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSearch implements GET /api/search. Runs three paged facets sharing
|
||||||
|
// one limit/offset. Each facet returns its own total reflecting the full
|
||||||
|
// match count (not just the page). Nil slices are explicitly [] so JSON
|
||||||
|
// doesn't emit null.
|
||||||
|
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if q == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "q is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbQ := dbq.New(h.pool)
|
||||||
|
needle := &q
|
||||||
|
|
||||||
|
artists, err := dbQ.SearchArtists(r.Context(), dbq.SearchArtistsParams{
|
||||||
|
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: search artists failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: count artists matching failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artistItems := make([]ArtistRef, 0, len(artists))
|
||||||
|
for _, a := range artists {
|
||||||
|
albums, aerr := dbQ.ListAlbumsByArtist(r.Context(), a.ID)
|
||||||
|
if aerr != nil {
|
||||||
|
h.logger.Error("api: list albums for search artist failed", "err", aerr)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artistItems = append(artistItems, artistRefFrom(a, len(albums)))
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := dbQ.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
|
||||||
|
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: search albums failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: count albums matching failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{
|
||||||
|
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: search tracks failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackTotal, err := dbQ.CountTracksMatching(r.Context(), q)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: count tracks matching failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, SearchResponse{
|
||||||
|
Artists: Page[ArtistRef]{Items: artistItems, Total: int(artistTotal), Limit: limit, Offset: offset},
|
||||||
|
Albums: Page[AlbumRef]{Items: albumItems, Total: int(albumTotal), Limit: limit, Offset: offset},
|
||||||
|
Tracks: Page[TrackRef]{Items: trackItems, Total: int(trackTotal), Limit: limit, Offset: offset},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlbumRefs builds AlbumRefs from dbq.Album rows: resolves artist
|
||||||
|
// name (one query per distinct artist) and track count per album (one
|
||||||
|
// query each). Acceptable at limit=200 page size.
|
||||||
|
func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
|
||||||
|
items := make([]AlbumRef, 0, len(albums))
|
||||||
|
names, err := resolveArtistNames(ctx, q, collectArtistIDs(albums))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: resolve artist names failed", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range albums {
|
||||||
|
count, cerr := q.CountTracksByAlbum(ctx, a.ID)
|
||||||
|
if cerr != nil {
|
||||||
|
h.logger.Error("api: count tracks for album failed", "err", cerr)
|
||||||
|
return nil, cerr
|
||||||
|
}
|
||||||
|
// durationSec=0: not aggregated for search album refs per spec data flow.
|
||||||
|
items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the
|
||||||
|
// parent album + artist by id for each track (pair of queries per track).
|
||||||
|
// At limit=200 this is 400 queries worst case — acceptable for M1.5.
|
||||||
|
func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) {
|
||||||
|
items := make([]TrackRef, 0, len(tracks))
|
||||||
|
for _, t := range tracks {
|
||||||
|
album, aerr := q.GetAlbumByID(ctx, t.AlbumID)
|
||||||
|
if aerr != nil {
|
||||||
|
h.logger.Error("api: get album for track failed", "err", aerr)
|
||||||
|
return nil, aerr
|
||||||
|
}
|
||||||
|
artist, aerr := q.GetArtistByID(ctx, t.ArtistID)
|
||||||
|
if aerr != nil {
|
||||||
|
h.logger.Error("api: get artist for track failed", "err", aerr)
|
||||||
|
return nil, aerr
|
||||||
|
}
|
||||||
|
items = append(items, trackRefFrom(t, album.Title, artist.Name))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectArtistIDs extracts the distinct-preserving ordered list of artist
|
||||||
|
// ids from a slice of albums. resolveArtistNames dedupes internally.
|
||||||
|
func collectArtistIDs(albums []dbq.Album) []pgtype.UUID {
|
||||||
|
ids := make([]pgtype.UUID, 0, len(albums))
|
||||||
|
for _, a := range albums {
|
||||||
|
ids = append(ids, a.ArtistID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveArtistNames fetches each distinct artist id and returns a
|
||||||
|
// uuid-string → name map. Duplicated from subsonic intentionally.
|
||||||
|
func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) {
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
out := make(map[string]string, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
key := uuidToString(id)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
a, err := q.GetArtistByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[key] = a.Name
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleSearch_ThreeFacets(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
artist := seedArtist(t, pool, "Beatles")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Beatles Greatest", 1982)
|
||||||
|
seedTrack(t, pool, album.ID, artist.ID, "Beatles Jam", 1, 60_000)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/search?q=beatles", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got SearchResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.Artists.Total != 1 || len(got.Artists.Items) != 1 {
|
||||||
|
t.Errorf("artists = %+v", got.Artists)
|
||||||
|
}
|
||||||
|
if got.Albums.Total != 1 || len(got.Albums.Items) != 1 {
|
||||||
|
t.Errorf("albums = %+v", got.Albums)
|
||||||
|
}
|
||||||
|
if got.Tracks.Total != 1 || len(got.Tracks.Items) != 1 {
|
||||||
|
t.Errorf("tracks = %+v", got.Tracks)
|
||||||
|
}
|
||||||
|
if got.Artists.Items[0].Name != "Beatles" {
|
||||||
|
t.Errorf("artist name = %q", got.Artists.Items[0].Name)
|
||||||
|
}
|
||||||
|
if got.Albums.Items[0].ArtistName != "Beatles" {
|
||||||
|
t.Errorf("album.artist_name = %q", got.Albums.Items[0].ArtistName)
|
||||||
|
}
|
||||||
|
if got.Tracks.Items[0].AlbumTitle != "Beatles Greatest" {
|
||||||
|
t.Errorf("track.album_title = %q", got.Tracks.Items[0].AlbumTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch_NoMatches(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
seedArtist(t, pool, "Beatles")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/search?q=nothinghere", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got SearchResponse
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Artists.Total != 0 || got.Albums.Total != 0 || got.Tracks.Total != 0 {
|
||||||
|
t.Errorf("totals = %+v", got)
|
||||||
|
}
|
||||||
|
// All three item slices must JSON-encode as [] not null.
|
||||||
|
if got.Artists.Items == nil || got.Albums.Items == nil || got.Tracks.Items == nil {
|
||||||
|
t.Errorf("nil item slice: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch_EmptyQuery(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/search", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch_BlankQuery(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/search?q=%20%20", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSearch_PagingAppliedPerFacet(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
// Create three matching artists so paging shows.
|
||||||
|
a1 := seedArtist(t, pool, "Match One")
|
||||||
|
a2 := seedArtist(t, pool, "Match Two")
|
||||||
|
a3 := seedArtist(t, pool, "Match Three")
|
||||||
|
_ = a1
|
||||||
|
_ = a2
|
||||||
|
_ = a3
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/search?q=Match&limit=2&offset=1", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newLibraryRouter(h).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var got SearchResponse
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||||
|
if got.Artists.Total != 3 {
|
||||||
|
t.Errorf("total = %d, want 3", got.Artists.Total)
|
||||||
|
}
|
||||||
|
if got.Artists.Limit != 2 || got.Artists.Offset != 1 {
|
||||||
|
t.Errorf("page = limit=%d offset=%d", got.Artists.Limit, got.Artists.Offset)
|
||||||
|
}
|
||||||
|
if len(got.Artists.Items) != 2 {
|
||||||
|
t.Errorf("items len = %d, want 2", len(got.Artists.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,3 +24,71 @@ type UserView struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Page is the generic pagination envelope used by list and search endpoints.
|
||||||
|
// Items is always non-nil at JSON encode time — handlers must initialize
|
||||||
|
// empty slices explicitly so absent results render as [] rather than null.
|
||||||
|
type Page[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistRef is the lightweight artist shape used in lists and search results.
|
||||||
|
type ArtistRef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumCount int `json:"album_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumRef is the lightweight album shape used in lists, artist details,
|
||||||
|
// and search results. CoverURL points to /api/albums/{id}/cover which
|
||||||
|
// Plan 3 implements.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackRef is the lightweight track shape used in album details and search.
|
||||||
|
// StreamURL points to /api/tracks/{id}/stream which Plan 3 implements.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref
|
||||||
|
// so callers read id/name/album_count from the same path as list entries.
|
||||||
|
type ArtistDetail struct {
|
||||||
|
ArtistRef
|
||||||
|
Albums []AlbumRef `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||||||
|
type AlbumDetail struct {
|
||||||
|
AlbumRef
|
||||||
|
Tracks []TrackRef `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
||||||
|
// total + limit + offset; the client sends one shared limit/offset and the
|
||||||
|
// server applies it uniformly to each facet's LIMIT/OFFSET query.
|
||||||
|
type SearchResponse struct {
|
||||||
|
Artists Page[ArtistRef] `json:"artists"`
|
||||||
|
Albums Page[AlbumRef] `json:"albums"`
|
||||||
|
Tracks Page[TrackRef] `json:"tracks"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countAlbumsMatching = `-- name: CountAlbumsMatching :one
|
||||||
|
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountAlbumsMatching(ctx context.Context, dollar_1 string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAlbumsMatching, dollar_1)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one
|
const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one
|
||||||
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countArtists = `-- name: CountArtists :one
|
||||||
|
SELECT COUNT(*) FROM artists
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountArtists(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countArtists)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const countArtistsMatching = `-- name: CountArtistsMatching :one
|
||||||
|
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountArtistsMatching(ctx context.Context, dollar_1 string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countArtistsMatching, dollar_1)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const getArtistByID = `-- name: GetArtistByID :one
|
const getArtistByID = `-- name: GetArtistByID :one
|
||||||
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = $1
|
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -78,6 +100,79 @@ func (q *Queries) ListArtists(ctx context.Context) ([]Artist, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listArtistsAlpha = `-- name: ListArtistsAlpha :many
|
||||||
|
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY sort_name, name, id LIMIT $1 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListArtistsAlphaParams struct {
|
||||||
|
Limit int32
|
||||||
|
Offset int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListArtistsAlpha(ctx context.Context, arg ListArtistsAlphaParams) ([]Artist, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listArtistsAlpha, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Artist
|
||||||
|
for rows.Next() {
|
||||||
|
var i Artist
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.SortName,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listArtistsNewest = `-- name: ListArtistsNewest :many
|
||||||
|
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListArtistsNewestParams struct {
|
||||||
|
Limit int32
|
||||||
|
Offset int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary id tiebreaker keeps pagination stable when created_at ties.
|
||||||
|
func (q *Queries) ListArtistsNewest(ctx context.Context, arg ListArtistsNewestParams) ([]Artist, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listArtistsNewest, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Artist
|
||||||
|
for rows.Next() {
|
||||||
|
var i Artist
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.SortName,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const searchArtists = `-- name: SearchArtists :many
|
const searchArtists = `-- name: SearchArtists :many
|
||||||
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists
|
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists
|
||||||
WHERE name ILIKE '%' || $1 || '%'
|
WHERE name ILIKE '%' || $1 || '%'
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ func (q *Queries) CountTracksByAlbum(ctx context.Context, albumID pgtype.UUID) (
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countTracksMatching = `-- name: CountTracksMatching :one
|
||||||
|
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountTracksMatching(ctx context.Context, dollar_1 string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countTracksMatching, dollar_1)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -53,3 +53,6 @@ SELECT * FROM albums
|
|||||||
WHERE title ILIKE '%' || $1 || '%'
|
WHERE title ILIKE '%' || $1 || '%'
|
||||||
ORDER BY sort_title
|
ORDER BY sort_title
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT $2 OFFSET $3;
|
||||||
|
|
||||||
|
-- name: CountAlbumsMatching :one
|
||||||
|
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
|
||||||
|
|||||||
@@ -24,3 +24,16 @@ SELECT * FROM artists
|
|||||||
WHERE name ILIKE '%' || $1 || '%'
|
WHERE name ILIKE '%' || $1 || '%'
|
||||||
ORDER BY sort_name
|
ORDER BY sort_name
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT $2 OFFSET $3;
|
||||||
|
|
||||||
|
-- name: ListArtistsAlpha :many
|
||||||
|
SELECT * FROM artists ORDER BY sort_name, name, id LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
-- name: ListArtistsNewest :many
|
||||||
|
-- Secondary id tiebreaker keeps pagination stable when created_at ties.
|
||||||
|
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 || '%';
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ SELECT * FROM tracks
|
|||||||
WHERE title ILIKE '%' || $1 || '%'
|
WHERE title ILIKE '%' || $1 || '%'
|
||||||
ORDER BY title
|
ORDER BY title
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT $2 OFFSET $3;
|
||||||
|
|
||||||
|
-- name: CountTracksMatching :one
|
||||||
|
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
|
||||||
|
|||||||
Reference in New Issue
Block a user