1
0

Add design spec for record-shop webapp

This commit is contained in:
2026-08-29 13:59:35 +02:00
parent 25315247bc
commit 1dfe47f5dd

View 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 (34 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