Add design spec for record-shop webapp
This commit is contained in:
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal file
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user