diff --git a/docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md b/docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md new file mode 100644 index 0000000..9e8b6b5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md @@ -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 `