diff --git a/docs/superpowers/specs/2026-08-29-record-shop-design.md b/docs/superpowers/specs/2026-08-29-record-shop-design.md new file mode 100644 index 0000000..74aa526 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-record-shop-design.md @@ -0,0 +1,138 @@ +# record-shop — Design Spec + +**Date:** 2026-08-29 +**Status:** Approved design, pending implementation + +## Purpose + +A self-hosted webapp for cataloguing a physical music collection (vinyl, CDs, cassettes) and tracking what has been ripped into the owner's digital library. The flagship interaction is scanning a physical item's barcode with a phone camera to add it to the collection in seconds, with metadata pulled from Discogs and an automatic check against the digital library. + +**Success criteria:** + +- Deployable with `docker compose up -d` and zero required env vars +- Adding a barcoded item takes under 15 seconds on a phone +- Rip status is visibly accurate for items whose album exists in a Subsonic-compatible library +- Usable one-handed on a phone; installable as a PWA + +## Architecture + +Single Node.js container running a Fastify server that serves: + +- The REST API under `/api/*` +- The built React SPA (Vite + Tailwind CSS, mobile-first) + +**Storage:** SQLite via `better-sqlite3` (WAL mode), database file at `/data/record-shop.db`. Artwork fetched from Discogs is proxied through the server and cached on disk at `/data/artwork-cache/`, keyed by Discogs image URL hash. + +**Barcode decoding** happens client-side: the native `BarcodeDetector` API where available (most Android browsers), with a ZXing-wasm fallback for iOS Safari. Supported symbologies: EAN-13, UPC-A, EAN-8. + +**PWA:** manifest + minimal service worker so the app installs to a phone home screen. + +### Dependencies (per-container, no external services) + +| Concern | Choice | +| --- | --- | +| HTTP server | Fastify | +| Database | SQLite (better-sqlite3, WAL) | +| Sessions | httpOnly cookie, server-side session store in SQLite; secret generated at first boot and persisted in `/data` | +| Password hashing | argon2 | +| Frontend | React + Vite + Tailwind CSS | +| Barcode decode | `BarcodeDetector` API, ZXing-wasm fallback | +| Discogs access | Personal access token (free), per user | +| Digital library | Subsonic API (Navidrome, Gonic, Airsonic, LMS, …) | + +## Users and auth + +- First run shows a setup screen that creates the admin account. Admin creates additional users. +- Auth: username + password (argon2), httpOnly session cookie. +- Multi-user with **separate private collections**: each user scans into and sees only their own collection. +- Per-user settings, stored server-side: Discogs personal access token; Subsonic URL, username, and password. + +## Data model (SQLite) + +- `users` — id, username, password_hash, is_admin, created_at +- `settings` — user_id (PK), discogs_token, subsonic_url, subsonic_username, subsonic_password +- `collection_items` — id, user_id, discogs_release_id, title, artist, year, formats (JSON), local_artwork_path, barcodes (JSON, may be empty), date_added. Unique on (user_id, discogs_release_id). +- `rip_status` on each collection item: + - `null` — automatic: derived from library match state + - `true` / `false` — manual override, wins over everything +- `digital_albums` — id, user_id, subsonic_id, title, artist, synced_at. Per-user cache of the Subsonic library, refreshed by sync. +- `match_links` — user_id, collection_item_id, digital_album_id. Manual mapping between a physical item and a digital album, set when fuzzy matching is ambiguous or wrong. + +Discogs metadata (title, artist, year, formats, tracklist, labels, genres, images) is denormalized into `collection_items` and the disk cache at add time, so the collection remains readable even if Discogs is unreachable later. + +## Core flows + +### Scan flow (primary) + +1. Tap the scan tab → camera view opens with a viewfinder overlay guiding the barcode into frame. +2. Barcode decoded client-side → `GET /api/lookup/barcode/:code` → server queries Discogs `database/search?barcode=...&type=release`. +3. **0 results** → "Nothing found" screen with a one-tap text-search fallback. +4. **1+ results** → candidate cards (cover, title, artist, year, format, country). User taps the correct edition. +5. Server fetches the full release (`GET /releases/:id`), then: + - Runs the rip check against the user's `digital_albums` cache. + - Checks for a duplicate: same (user, discogs_release_id) already in the collection → warning shown. +6. Confirm screen: cover, metadata, **"In your digital collection ✓ / Not ripped yet ✗"** banner, "Add to collection" button. +7. Item added → user lands on its detail page. + +### Text-search flow (fallback for unscannable items) + +Same pipeline from step 4 onward, via `GET /api/lookup/search?q=...`, filterable by format. Entry point on the Add tab. Used for most vinyl and cassettes. + +### Library sync flow + +- "Sync library now" (Settings and Library screens) paginates Subsonic `getAlbumList2`, upserting into `digital_albums`. +- Runs in the background with a progress indicator; "last synced X ago" stamp shown. +- Saving a Subsonic config triggers a first sync immediately. + +### Rip status resolution (computed per item, cached) + +1. Manual override (`rip_status` non-null) wins, always. +2. Else a manual `match_link` decides. +3. Else fuzzy auto-match against `digital_albums`: confident match → ripped; no match → not ripped. +4. Library re-syncs never clobber manual overrides or confirmed match links. + +**Fuzzy matching rule:** normalize strings (lowercase, strip punctuation, strip leading articles "the"/"a"/"an", collapse whitespace). A **confident match** requires normalized artist and normalized title to be equal. Anything else (near-misses, multiple candidates, no match) is **ambiguous**: the app asks the user to confirm once from a short candidate list, and the confirmed link is stored in `match_links`. + +### Discogs rate limiting + +Authenticated Discogs allows 60 requests/minute. All Discogs calls go through a server-side serializing queue. Full release payloads are cached on disk keyed by release ID, so re-scans and re-visits cost zero Discogs calls. + +## UI + +Bottom tab bar on mobile: **Library · Scan · Add · Settings**. Desktop renders the same app with a wider responsive grid — no separate layout. + +- **Library** — responsive cover grid (3–4 columns on phone, more on desktop). Filter chips: format (All / Vinyl / CD / Cassette) and rip state (All / Ripped / Not ripped). Search box and artist jump-list. Tap a cover → detail. +- **Item detail** — large cover; Discogs metadata (label, cat#, year, formats, genres, tracklist); rip-status banner; barcode(s); manual rip toggle; "Re-match" button to fix a wrong match; link to Discogs; edit/remove. +- **Add** — camera entry point plus text search. +- **Settings** — Discogs and Subsonic config; library sync; admin user management. + +## Error handling + +- Camera permission denied → explanation screen with retry. +- Discogs token missing/invalid → persistent banner linking to Settings. +- Discogs 429 → queue absorbs bursts; if the user still hits it, a "slow down, retrying" indicator. +- Subsonic unreachable → banner on Library/Settings with "Retry sync". +- Barcode not found in Discogs (common on older vinyl) → text-search fallback offered. +- Failures are always user-visible with plain phrasing; a scan is never silently lost. + +## Testing + +- **Backend (Vitest):** fuzzy matcher (unit); Discogs and Subsonic clients with mocked HTTP; API integration tests against in-memory SQLite; rate-limit queue behavior. +- **Frontend (Vitest + Testing Library):** scan-flow state machine (decode → candidates → confirm → added), candidate cards, filters. +- **Manual device checklist:** camera/scanning on iOS Safari and Android Chrome — the one component worth testing on real hardware. +- `docker build` verified before each release. + +## Deployment + +- Multi-stage Dockerfile: build stage → slim runtime image. +- Exposes port `3000`; one volume at `/data`. +- `docker-compose.yml` in the repo as the blessed path. +- Setup flow after first boot: open browser → create admin → paste Discogs token (and optionally Subsonic config) → start scanning. + +## Explicitly out of scope (v1) + +- Marketplace/wantlist features of Discogs +- Per-disc rip granularity (compilations where only some discs are ripped) +- Native mobile app +- Other music-server APIs (Jellyfin, Plex) — Subsonic first, by design +- Multi-collection sharing, household accounts