docs: wave 1 design spec (export/backup, stats, loans, rip queue, listening bridge)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Wave 1 — Hygiene + Listening Bridge — Design Spec
|
||||
|
||||
**Date:** 2026-09-03
|
||||
**Status:** Approved design, pending implementation
|
||||
**Parent:** docs/superpowers/specs/2026-08-29-record-shop-design.md
|
||||
**Scope:** Export/backup, stats wall, loan tracking, rip queue, listening bridge (built-in player). MusicBrainz fallback and copies/condition are explicitly deferred (see plan-2 post-implementation decisions).
|
||||
|
||||
## Purpose
|
||||
|
||||
Turn rip status from a label into a workflow (rip queue), connect the physical collection to listening (built-in player + last-played), protect selfhosted data (export + backups), and surface the collection's shape (stats). All on the existing Fastify/SQLite/React stack with the frozen plan-1 API as the base.
|
||||
|
||||
## Schema versioning (prerequisite)
|
||||
|
||||
`migrate()` gains versioned steps: `app_meta.schema_version` (absent = v1) and guarded `ALTER TABLE` upgrades. v2 changes:
|
||||
|
||||
- `ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT` — null = never played; set during sync
|
||||
- New table `loans`:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
|
||||
borrower TEXT NOT NULL,
|
||||
lent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
returned_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
A test migrates a pre-v1 fixture database and asserts both changes. Sync re-runs are idempotent.
|
||||
|
||||
## API additions (all require auth unless noted)
|
||||
|
||||
| Method | Path | Behavior |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/stream/:songId` | Proxies Subsonic `stream?id=songId` with the user's credentials; `Range` request header passed through, response status/headers (Content-Type, Content-Range, Accept-Ranges) passed back — seeking works. Upstream failure → 502 `stream_unavailable` |
|
||||
| GET | `/api/album/:subsonicId/tracks` | Proxies `getAlbum` → `{album: {id, title, artist}, tracks: [{id, title, duration, track}]}` ordered by track number |
|
||||
| GET | `/api/stats` | `{totals: {items, ripped, notRipped, onLoan}, formats: [{name, count}], topGenres: [{name, count}] (≤10), topArtists: [{name, count}] (≤10), addedByMonth: [{month: 'YYYY-MM', count}] (last 12), ripRatio: 0..1}` — computed from the user's items + active loans |
|
||||
| GET | `/api/export` | `{exportedAt, items: [Item…], loans: […], matchLinks: […]}` — per authenticated user; **excludes settings/secrets** |
|
||||
| POST | `/api/backup` | Admin only. `better-sqlite3` `db.backup()` to `/data/backups/record-shop-YYYYMMDD-HHMMSS.db`; prunes to newest 7; returns `{file}` |
|
||||
| GET | `/api/backups` | `{backups: [{file, sizeBytes, createdAt}]}` newest first |
|
||||
| POST | `/api/collection/:id/loan` | `{borrower}` (non-empty string) → loan row; 400 `invalid_input` if borrower empty; 409 `already_on_loan` if an active loan exists for the item |
|
||||
| GET | `/api/loans` | `{active: […], history: […]}` (returned loans, newest first, ≤50) |
|
||||
| POST | `/api/loans/:id/return` | Sets `returned_at`; 404 if missing or already returned |
|
||||
|
||||
**Detail enrichment:** `GET /api/collection/:id` response gains `matchedAlbum: {id: number, subsonicId: string, lastPlayedAt: string | null} | null` — resolved with the same order as rip status (override does not affect it; match_link wins, else confident fuzzy match). List responses are unchanged.
|
||||
|
||||
**Sync change:** after upserting albums, sync fetches `getAlbumList2?type=recent&size=500` and sets `last_played_at` on matched rows (albums absent from the recent list keep their existing value; never-played stays null).
|
||||
|
||||
## Mini player (frontend)
|
||||
|
||||
- `PlayerProvider` (context + reducer) mounted above the router: `{tracks, index, playing, error}`. Audio survives navigation — one global `<audio>` element owned by the provider.
|
||||
- `GET /api/album/:subsonicId/tracks` loads the queue; each track streams from `/api/stream/:trackId`; `ended` auto-advances; last track ends the queue.
|
||||
- **Mini-bar** fixed above the tab bar when a queue exists: cover thumb, current track title, play/pause, seek bar (client-side over `duration`/`currentTime`). Tap expands to the full player: track list with per-track durations, prev/next, "stream unavailable — skip" affordance on track `error` (jumps to next track, flags the failed one).
|
||||
- Entry: Item page **Play** button, visible when `ripStatus === 'ripped'` and `matchedAlbum != null`.
|
||||
|
||||
## Pages & routing
|
||||
|
||||
- **Item page** — Play button; "Last played X ago" (humanized, under the rip banner, hidden when null); loan section: when not on loan → borrower input + "Lend"; when on loan → "Out to {borrower} since {date}" + "Returned" button.
|
||||
- **Library** — header row adds links **Stats · Queue**; filter chips gain **On loan** (active-loan items).
|
||||
- **Stats page** (`/stats`) — cards (total, ripped, not ripped, on loan) and pure-CSS bar charts (formats, top genres, top artists, added-by-month over 12 months) + rip-ratio donut (conic-gradient). No chart library.
|
||||
- **Queue page** (`/queue`) — not-ripped items, oldest `date_added` first; rows: cover, title/artist, "Mark ripped" (PATCH rip `true`, row removes), link into the item page for re-match.
|
||||
- **Settings** — new Data section: "Export JSON" (browser download of `/api/export`), "Back up now" (POST, shows result), last-backup time + backup list from `/api/backups`.
|
||||
- Routes `/stats` and `/queue` live inside the protected shell; no new tab bar entries.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Player track `error` → inline per-track flag + auto/skip affordance; provider never crashes; empty state when queue exhausted.
|
||||
- Export/backup/loans failures → flash banner (Settings) or inline error (Item page).
|
||||
- Lend with empty borrower → 400 `invalid_input` (HTML `required` also prevents it client-side); lend an on-loan item → 409 `already_on_loan` shown inline.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Server (Vitest, node):** stream proxy incl. Range passthrough and 502 mapping (mocked Subsonic); tracks endpoint ordering; stats aggregation on seeded data; loans CRUD + 409 + return; export shape asserting absence of settings/secrets; backup writes to tmp dir + prunes to 7; **migration v1→v2 on a fixture DB** (fixture = pre-v2 schema dump) + sync stamps last_played_at.
|
||||
- **Web (Vitest, jsdom):** player reducer (load/advance/error/skip), mini-bar + expanded player rendering and controls, queue page mark-ripped flow, stats page rendering, item page play/loan UI, settings data section.
|
||||
- Manual: audio playback on the phone (codec support, seeking) joins the device checklist.
|
||||
|
||||
## Explicitly out of scope (v1)
|
||||
|
||||
- Volume control, shuffle/repeat, persistent playback position across sessions
|
||||
- Offline playback / full offline mode
|
||||
- CSV export (JSON only), restore-from-backup UI
|
||||
- Now-playing indicator from other users' sessions
|
||||
Reference in New Issue
Block a user