1
0

Compare commits

...

76 Commits

Author SHA1 Message Date
d3c8754a5d fix: hide navidrome link when webUrl is null (cleared subsonic url) 2026-09-04 14:04:24 +02:00
642ca52c71 feat: navidrome deep link replaces in-app player 2026-09-04 13:45:12 +02:00
165ac96830 feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes 2026-09-04 13:37:01 +02:00
ff5f4e9133 docs: navidrome deep-link implementation plan (3 tasks) 2026-09-04 13:27:00 +02:00
023557de64 docs: navidrome deep-link spec (replaces in-app player) 2026-09-04 13:23:49 +02:00
7b7b149928 fix: bodyless posts no longer send json content-type (fastify 400 on empty body) 2026-09-04 13:14:46 +02:00
3edf7574f9 fix: loan error surfacing, non-admin export, guarded atomic migration, expanded player controls 2026-09-03 23:50:54 +02:00
9b2bc22d59 feat: settings data section with export and backups 2026-09-03 23:17:21 +02:00
b33399c865 feat: stats page with css bar charts and rip-ratio donut 2026-09-03 23:08:09 +02:00
fc5797d929 feat: rip queue page, library stats/queue links and on-loan chip 2026-09-03 22:59:31 +02:00
47858b1788 fix: refetch item detail after re-match so the play button stays honest 2026-09-03 22:54:09 +02:00
ece18d125b feat: play button, last played, loan lend/return on item page 2026-09-03 22:45:20 +02:00
b2711b2b79 feat: mini player bar with expandable track list 2026-09-03 22:28:20 +02:00
c8b596bb02 feat: global player context with album queue and audio element 2026-09-03 22:15:19 +02:00
ee19bc773c feat: web api client additions for wave 1 2026-09-03 22:06:57 +02:00
3ce55ba09e feat: json export and sqlite backup endpoints 2026-09-03 21:57:38 +02:00
3176807268 feat: collection stats route 2026-09-03 21:45:52 +02:00
53b4bc39f5 feat: loan tracking routes 2026-09-03 21:37:06 +02:00
f8f240f00a feat: matchedAlbum on item detail, onLoan collection filter 2026-09-03 21:25:47 +02:00
d63bc4bc12 test: permanent coverage for stream/album proxy routes 2026-09-03 21:25:47 +02:00
ebac55b7a5 feat: last-played stamping via sync and played endpoint, stream/album proxy routes 2026-09-03 21:03:03 +02:00
9d1291b58f feat: subsonic getAlbum/getRecentAlbums and stream url builder 2026-09-03 20:53:49 +02:00
3bdbd8315c feat: schema versioning with v2 migration (loans, last_played_at) 2026-09-03 20:43:33 +02:00
bc3db9e4f7 docs: wave 1 implementation plan (15 tasks) 2026-09-03 20:37:38 +02:00
024cc6d953 docs: wave 1 design spec (export/backup, stats, loans, rip queue, listening bridge) 2026-09-03 20:23:40 +02:00
3fa0b02560 docs: record plan 2 post-implementation decisions 2026-09-03 19:28:30 +02:00
21a4c69972 fix: reset-to-auto in both rip branches, visible mutation failures, distinct invalid-token error 2026-09-03 19:24:57 +02:00
f2fff1d7be feat: docker builds the web app, full-stack serving verified 2026-09-03 19:05:57 +02:00
c1852435c8 feat: pwa manifest, icon and minimal service worker 2026-09-03 18:57:31 +02:00
3efde1a828 fix: sync-now feedback and polling, preserve stored subsonic password, fix placeholder 2026-08-30 00:16:41 +02:00
6b85f11871 feat: settings page with integrations, sync control and user management 2026-08-30 00:00:44 +02:00
7a58f9e5a0 fix: hide barcode line on add-page confirm view 2026-08-29 23:40:26 +02:00
bc62019162 feat: add page with discogs text search into confirm flow 2026-08-29 23:34:53 +02:00
71423c7ab7 fix: clear re-match selection when searching again 2026-08-29 23:27:54 +02:00
e912d9e485 feat: item detail with rip override, re-match and remove 2026-08-29 23:19:48 +02:00
b87e6abee0 feat: library grid with rip/format filters, search and artist index 2026-08-29 23:10:20 +02:00
b9ba4f0ca6 fix: dispatch ADD_START, correlate preview/lookup responses with the current attempt 2026-08-29 22:54:00 +02:00
c76d936f1e feat: scan flow page with candidates, confirm view and error guidance 2026-08-29 22:37:01 +02:00
890e7da3a7 feat: pure scan-flow state machine 2026-08-29 22:25:53 +02:00
70b7fdbe1b feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component 2026-08-29 22:11:01 +02:00
bda43ce237 feat: router, tab shell, setup and login pages 2026-08-29 19:50:10 +02:00
fdf200a960 feat: typed api client and auth context with setup gate 2026-08-29 19:34:02 +02:00
63e8c5f054 feat: react/vite/tailwind web scaffold with jsdom tests 2026-08-29 19:26:10 +02:00
26609d8b62 docs: frontend implementation plan (12 tasks) 2026-08-29 19:16:27 +02:00
b55a1f8cc1 fix: atomic setup transaction, prune devDependencies from runtime image 2026-08-29 18:46:07 +02:00
56ffda9e88 feat: release payload disk cache, batched rip status for list, artwork 404, stricter input handling 2026-08-29 18:44:08 +02:00
39c956e67d fix: sync removes albums no longer in subsonic, classify non-json responses as api errors 2026-08-29 18:39:46 +02:00
ba705d546a feat: docker deployment with compose quickstart 2026-08-29 18:07:43 +02:00
061f7349b5 fix: robust web/dist path resolution and graceful shutdown 2026-08-29 17:59:22 +02:00
a3bf420282 feat: serve built SPA with fallback and artwork cache 2026-08-29 17:46:52 +02:00
875bc1087c feat: background subsonic library sync with per-user state 2026-08-29 17:34:05 +02:00
f1d6320dfe fix: validate match album before discogs fetch, guard barcode type 2026-08-29 17:30:02 +02:00
566a9bfc0d feat: collection CRUD with artwork caching, rip override and match links 2026-08-29 17:19:17 +02:00
ded4e17849 feat: discogs lookup routes (barcode/search/release preview)
Known-red tests pending later tasks:
- 'reports duplicate when release already in collection' needs
  POST /api/collection (Task 15)
- 'reports ripped...' and 'reports ambiguous...' need the test-only
  POST /api/library/albums/test-seed route (Task 16)
2026-08-29 17:07:04 +02:00
e8a83eeefb feat: rip status resolution with override and match links 2026-08-29 16:57:34 +02:00
c49321ccf1 feat: match ae/oe ligatures in normalization 2026-08-29 16:54:59 +02:00
217c676658 feat: title/artist normalization and matching 2026-08-29 16:48:15 +02:00
c1dd232a2f fix: artwork cache never throws on body/disk failures 2026-08-29 16:43:45 +02:00
0faa74ae27 feat: best-effort artwork disk cache 2026-08-29 16:38:28 +02:00
9b52039fde fix: skip null album entries in subsonic pagination 2026-08-29 16:35:01 +02:00
dc63db54ba feat: subsonic library pagination 2026-08-29 16:31:08 +02:00
72f3fc8436 fix: harden discogs mappers against malformed payloads, name error classes, assert auth header 2026-08-29 16:24:40 +02:00
3715bb7447 feat: discogs client with barcode/text search and release fetch 2026-08-29 16:13:43 +02:00
b79a72a81b feat: serializing queue with min-interval pacing 2026-08-29 16:05:29 +02:00
a54f56834d fix: classify invalid subsonic URL as unreachable, type settings handlers, guard token mask and body types 2026-08-29 16:01:07 +02:00
e94ffb535b feat: per-user settings routes with subsonic validation 2026-08-29 15:51:58 +02:00
6dfaaf465d fix: map duplicate-username race to 409 and test session/cascade behavior 2026-08-29 15:46:34 +02:00
8bdbe292d5 feat: admin user management routes 2026-08-29 15:35:24 +02:00
1c52281feb refactor: type auth preHandlers and mirror clearCookie options 2026-08-29 15:31:36 +02:00
ad8b7cb22a feat: setup/login/logout/me auth routes 2026-08-29 15:20:42 +02:00
f48093319b feat: argon2 passwords and sqlite-backed sessions 2026-08-29 15:11:15 +02:00
dff7b4826d feat: real entrypoint using loadConfig 2026-08-29 15:05:46 +02:00
5545aa31fb feat: config loading and sqlite schema 2026-08-29 14:55:42 +02:00
01ad6fbb40 chore: scaffold node/ts/vitest project with health endpoint 2026-08-29 14:43:44 +02:00
52b2cbd6c5 docs: backend implementation plan (18 tasks, TDD) 2026-08-29 14:32:57 +02:00
1dfe47f5dd Add design spec for record-shop webapp 2026-08-29 13:59:35 +02:00
107 changed files with 24491 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
server/dist
web/dist
data
.git
.gitignore
*.log
.DS_Store

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
server/dist/
web/dist/
data/
*.log
.DS_Store
coverage/
.env*

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# ---- build stage: deps + compile + test ----
FROM node:22-slim AS build
WORKDIR /app
# Install build tools as a fallback for native modules without prebuilds
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json vitest.config.ts ./
COPY server ./server
COPY web ./web
RUN npm run build:server && npm run build:web && npm test
RUN npm prune --omit=dev
# ---- runtime stage ----
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
ENV PORT=3000
ENV DATA_DIR=/data
VOLUME /data
EXPOSE 3000
CMD ["node", "server/dist/index.js"]

View File

@@ -1,3 +1,30 @@
# record-shop # record-shop
Easily manage a physical collection of music. Easily manage a physical collection of music.
Scan barcodes with your phone to catalogue vinyl, CDs and cassettes, pull
metadata from Discogs, and see what you have (and haven't) ripped into your
Subsonic-compatible music server (Navidrome, Gonic, Airsonic, LMS, …).
## Quickstart
```bash
docker compose up -d
```
Open <http://localhost:3000>, create the admin account, then add your Discogs
token and (optionally) your Subsonic server details in Settings.
- Data lives in `./data` (SQLite database + cached cover art).
- Configure via env if you prefer: `PORT`, `DATA_DIR`.
## Development
```bash
npm install
npm test # vitest (server + web)
npm run dev # API on :3000 + Vite dev server on :5173 (proxied)
npm run build # server + web production build
```
Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`

0
SPEC.md Normal file
View File

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
record-shop:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/data
restart: unless-stopped

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
# Navidrome Deep Link Implementation Plan (plan 4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the in-app album player with a server-built Navidrome album link on the item page.
**Architecture:** Deletion-heavy change: the player (context, mini-bar, audio element), stream/tracks/played proxy routes, and their tests are removed. `matchedAlbum` in the item detail gains `webUrl`, built server-side from the user's Subsonic URL. Spec: `docs/superpowers/specs/2026-09-04-navidrome-link-design.md`.
**Tech Stack:** unchanged. **Prerequisites:** main (post Wave 1), 205/205 tests. Branch: `navidrome-link`.
---
### Task 1: Backend — webUrl in matchedAlbum; delete stream routes
**Files:**
- Modify: `server/src/routes/collectionRoutes.ts`, `server/src/app.ts`
- Delete: `server/src/routes/streamRoutes.ts`, `server/test/stream.test.ts`
- Test: `server/test/collection.test.ts` (modify), `server/test/lastplayed.test.ts` (modify)
- [ ] **Step 1: Modify the detail route in `server/src/routes/collectionRoutes.ts`** — build webUrl from the user's Subsonic URL (import `getSettings` from './settingsRoutes.js'):
```ts
const matched = findMatchedAlbum(db, request.user.id, row.id)
const settings = getSettings(db, request.user.id)
const webUrl =
matched && settings.subsonic_url
? `${settings.subsonic_url.replace(/\/+$/, '')}/app/#/album/${matched.subsonicId}`
: null
return {
...rowToItem(db, row),
matchedAlbum: matched ? { ...matched, webUrl } : null,
loan: ... // unchanged
}
```
- [ ] **Step 2: Update the matchedAlbum test** in `server/test/collection.test.ts` ('detail includes matchedAlbum') — `webUrl` requires the user's subsonic_url: before the first detail assertion, PUT /api/settings with a subsonic config (reuse the ping-stub pattern from settings.test.ts so the PUT succeeds), seed album `alb-9`, then assert `matchedAlbum` matches `{ subsonicId: 'alb-9', webUrl: 'http://navidrome.local/app/#/album/alb-9' }`. Keep the pre-seed assertion (`matchedAlbum` null).
- [ ] **Step 3: Delete the stream routes** — remove `server/src/routes/streamRoutes.ts`, `server/test/stream.test.ts`, and the import/registration lines in `server/src/app.ts`. Update `server/test/lastplayed.test.ts`: the 'played endpoint' test is removed (route deleted); keep the sync-stamping test (it exercises sync.ts, which stays).
- [ ] **Step 4: Run tests**`npm test && npm run typecheck` — expect ALL PASS (205 2 played/stream tests + 0 new = 203; exact count may shift ±1 with the lastplayed restructure — report it).
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes"
```
---
### Task 2: Web — remove player, add the link
**Files:**
- Modify: `web/src/pages/ItemPage.tsx`, `web/src/App.tsx`, `web/src/api.ts`, `web/src/types.ts`, `web/test/item.test.tsx`, `web/test/api.test.ts`
- Delete: `web/src/player/` (PlayerContext.tsx, MiniBar.tsx), `web/test/player.test.tsx`, `web/test/minibar.test.tsx`
- [ ] **Step 1: Update failing tests first in `web/test/item.test.tsx`:**
- Remove PlayerProvider from `renderItem`, media prototype mocks, getAlbumTracks/markPlayed/lendItem mock entries where player-related; keep lendItem/returnLoan (loans stay)
- The rippedItem fixture keeps `matchedAlbum: matched` — extend `matched` with `webUrl: 'http://navidrome.local/app/#/album/alb-1'`
- Replace the 'shows Play…loads the player' test with: ripped+matched → link `[aria-label="Listen in Navidrome"]`... simpler: `getByRole('link', { name: /listen in navidrome/i })` with `getAttribute('href')` = the webUrl and `target` = '_blank'
- 'hides Play when unmatched or not ripped' → renamed: link absent when `matchedAlbum: null` (even when ripped)
- 'shows last played' unchanged
- lend/return tests unchanged
- [ ] **Step 2: Implement `web/src/pages/ItemPage.tsx`:** remove `usePlayer` import + `load` usage + media code; replace the Play button block with:
```tsx
{item.matchedAlbum && (
<a
href={item.matchedAlbum.webUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
>
Listen in Navidrome
</a>
)}
```
- [ ] **Step 3: Remove the player from the app:** delete `web/src/player/`, `web/test/player.test.tsx`, `web/test/minibar.test.tsx`; revert `web/src/App.tsx` wiring to `<Gate><Shell /></Gate>` (no PlayerProvider/MiniBar imports).
- [ ] **Step 4: Remove dead api/types:** delete `getAlbumTracks`, `markPlayed`, `streamUrl` methods from `web/src/api.ts`; delete `Track`, `AlbumTracks` interfaces from `web/src/types.ts` AND add `webUrl: string` to the `MatchedAlbum` interface (the detail route now supplies it); remove their entries + the 'album tracks + played + stream url' test from `web/test/api.test.ts`.
- [ ] **Step 5: Run tests**`npm test && npm run typecheck` — expect ALL PASS (203 3 player tests 3 minibar tests 1 api test-block + 1 item rewrite ≈ 197; report exact).
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: navidrome deep link replaces in-app player"
```
---
### Task 3: Verification + redeploy
- [ ] **Step 1:** `npm test && npm run typecheck && npm run build` — all green, both dists.
- [ ] **Step 2:** Restart the deployed instance (anchored pkill + setsid nohup pattern; verify in a separate invocation): health ok, SPA title served, `curl -X POST .../api/album/a1/played` → 404 (route gone).
- [ ] **Step 3:** No commit expected; report results.
---
## Verification checklist (manual)
- Item page for a ripped, matched album: 'Listen in Navidrome ↗' opens the correct album in Navidrome (desktop + phone)
- 'Last played X ago' still populates after a library sync (Navidrome-provided timestamps)
- No mini-bar anywhere; audio nowhere

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

View File

@@ -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

View File

@@ -0,0 +1,39 @@
# Replace In-App Player with Navidrome Deep Link — Design Spec
**Date:** 2026-09-04
**Status:** Approved design, pending implementation
**Parent:** docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md (supersedes its "Mini player" section)
## Purpose
The built-in album player (global audio element + mini-bar) is removed. Listening happens in the user's Navidrome web UI; the item page links directly to the matched album there. Rationale: Navidrome's player is better than ours, and the in-app player added a global-audio architecture for a preview-quality experience.
## Removals
- `web/src/player/` (PlayerContext, MiniBar) and their tests; PlayerProvider/MiniBar wiring in `web/src/App.tsx`
- Backend stream/tracks/played routes (`server/src/routes/streamRoutes.ts`) and their tests
- api methods `getAlbumTracks`, `markPlayed`, `streamUrl`; types `Track`, `AlbumTracks`; ItemPage's `usePlayer`/`load` wiring and media mocks
- Media-prototype mocks in `web/test/item.test.tsx` (no audio element remains)
## Changes
- **`GET /api/collection/:id` detail** — `matchedAlbum` gains `webUrl: string`: the Navidrome album URL built server-side as `<subsonic_url>/app/#/album/<subsonicId>` (trailing slashes stripped from the configured base; null-safe — `matchedAlbum` stays null when unmatched, and when no Subsonic config exists `webUrl` is still constructed from the stored URL only if the album row exists; unmatched → null either way).
- **Item page** — the ▶ Play album button becomes an anchor **"Listen in Navidrome ↗"** (`target="_blank"` `rel="noreferrer"`), shown whenever `matchedAlbum != null` (regardless of rip status — the album exists in Navidrome if it matched).
- **"Last played X ago"** stays. Stamping now comes solely from library sync reading the server's recently-played list (`getAlbumList2?type=recent`, opportunistic `played`/`playedAt` field). No in-app stamping.
## Unchanged
Stats, loans, rip queue, export/backup, sync, matching, all plan-1/2 APIs except the removed routes.
## Error handling
No new failure modes. The link is a static anchor; a wrong Subsonic URL produces a broken link the same way a wrong URL breaks sync — surfaced by Settings, not the item page.
## Testing
- Backend: detail test asserts `matchedAlbum.webUrl` (`http://n.local/app/#/album/<subsonicId>`); stream tests deleted with the routes
- Web: item tests assert the link (href + target) replaces the player tests; api.test drops the removed methods; player/minibar test files deleted
## Manual device checklist
- Link opens Navidrome's album page from the phone (PWA → browser tab)

5173
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "record-shop",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev:server": "tsx watch server/src/index.ts",
"dev:web": "vite --config web/vite.config.ts",
"dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"",
"test": "vitest run",
"test:watch": "vitest",
"build:server": "tsc -p server/tsconfig.build.json",
"build:web": "vite build --config web/vite.config.ts",
"build": "npm run build:server && npm run build:web",
"start": "node server/dist/index.js",
"typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p web --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/static": "^8.0.0",
"@zxing/library": "^0.23.0",
"argon2": "^0.41.1",
"better-sqlite3": "^13.0.3",
"fastify": "^5.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.5.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^5.2.0",
"concurrently": "^10.0.5",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.16.0",
"typescript": "^5.5.4",
"vite": "^7.3.6",
"vitest": "^3.0.0"
}
}

89
server/src/app.ts Normal file
View File

@@ -0,0 +1,89 @@
import Fastify, { FastifyInstance } from 'fastify'
import Database from 'better-sqlite3'
import cookie from '@fastify/cookie'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import type { Config } from './config.js'
import { SerialQueue } from './queue.js'
import { SyncManager } from './sync.js'
import { ReleaseCache } from './releaseCache.js'
import { registerAuthRoutes } from './routes/authRoutes.js'
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
import { registerLookupRoutes } from './routes/lookupRoutes.js'
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
import { registerLoanRoutes } from './routes/loanRoutes.js'
import { registerStatsRoutes } from './routes/statsRoutes.js'
import { registerDataRoutes } from './routes/dataRoutes.js'
declare module 'fastify' {
interface FastifyInstance {
db: Database.Database
config: Config
fetchImpl: typeof fetch
discogsQueue: SerialQueue
sync: SyncManager
releaseCache: ReleaseCache
}
interface FastifyRequest {
user?: import('./auth.js').UserRow
}
}
export interface AppOptions {
db: Database.Database
config: Config
fetchImpl?: typeof fetch
/** Directory of the built SPA. Defaults to web/dist when it exists. */
webDist?: string
}
export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
const app = Fastify({ logger: false })
app.decorate('db', opts.db)
app.decorate('config', opts.config)
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 }))
app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch))
app.decorate('releaseCache', new ReleaseCache(path.join(opts.config.dataDir, 'release-cache')))
await app.register(cookie)
await registerAuthRoutes(app)
await registerSettingsRoutes(app)
await registerLookupRoutes(app)
await registerCollectionRoutes(app)
await registerLibraryRoutes(app)
await registerLoanRoutes(app)
await registerStatsRoutes(app)
await registerDataRoutes(app)
// artwork cache (always available)
await app.register(fastifyStatic, {
root: opts.config.artworkDir,
prefix: '/artwork/',
decorateReply: false,
})
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
const webDist = opts.webDist ?? path.resolve(moduleDir, '../../web/dist')
const hasWeb = existsSync(webDist)
if (hasWeb) {
await app.register(fastifyStatic, { root: webDist, prefix: '/' })
}
app.setNotFoundHandler((request, reply) => {
const url = request.raw.url ?? ''
if (url.startsWith('/api') || url.startsWith('/artwork')) {
return reply.code(404).send({ error: 'not_found' })
}
if (hasWeb) {
return reply.sendFile('index.html')
}
return reply.code(404).send({ error: 'not_found' })
})
app.get('/api/health', async () => ({ ok: true }))
return app
}

45
server/src/artwork.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
const EXT_BY_TYPE: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
}
/**
* Downloads `url` into `artworkDir` keyed by its sha256 hash. Returns the
* stored file name (e.g. `abc123….jpg`) or null when the url is empty or the
* download fails — artwork caching is best-effort and must never break adds.
*/
export async function cacheArtwork(
artworkDir: string,
url: string,
fetchImpl: typeof fetch = fetch
): Promise<string | null> {
if (!url) return null
const key = createHash('sha256').update(url).digest('hex')
try {
mkdirSync(artworkDir, { recursive: true })
for (const ext of Object.values(EXT_BY_TYPE)) {
if (existsSync(path.join(artworkDir, key + ext))) return key + ext
}
let res: Response
try {
res = await fetchImpl(url)
} catch {
return null
}
if (!res.ok) return null
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? ''
const ext = EXT_BY_TYPE[type] ?? '.jpg'
const buf = Buffer.from(await res.arrayBuffer())
await writeFile(path.join(artworkDir, key + ext), buf)
return key + ext
} catch {
return null
}
}

66
server/src/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
import crypto from 'node:crypto'
import argon2 from 'argon2'
import type { DB } from './db.js'
export interface UserRow {
id: number
username: string
password_hash: string
is_admin: number
created_at: string
}
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password)
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password)
}
export function createUser(db: DB, username: string, passwordHash: string, isAdmin: boolean): UserRow {
const info = db
.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)')
.run(username, passwordHash, isAdmin ? 1 : 0)
return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as UserRow
}
export function getUserByUsername(db: DB, username: string): UserRow | undefined {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined
}
export function getUserById(db: DB, id: number): UserRow | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined
}
export function createSession(db: DB, userId: number): string {
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString()
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
token,
userId,
expiresAt
)
return token
}
export function getUserBySession(db: DB, token: string, now: () => number = Date.now): UserRow | null {
const row = db
.prepare(
`SELECT u.*, s.expires_at FROM sessions s
JOIN users u ON u.id = s.user_id WHERE s.token = ?`
)
.get(token) as (UserRow & { expires_at: string }) | undefined
if (!row) return null
if (new Date(row.expires_at).getTime() < now()) {
deleteSession(db, token)
return null
}
return row
}
export function deleteSession(db: DB, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}

42
server/src/config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
export interface Config {
dataDir: string
artworkDir: string
backupsDir: string
dbPath: string
port: number
sessionSecret: string
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const dataDir = env.DATA_DIR ?? path.resolve('data')
mkdirSync(dataDir, { recursive: true })
const artworkDir = path.join(dataDir, 'artwork-cache')
mkdirSync(artworkDir, { recursive: true })
const backupsDir = path.join(dataDir, 'backups')
mkdirSync(backupsDir, { recursive: true })
return {
dataDir,
artworkDir,
backupsDir,
dbPath: path.join(dataDir, 'record-shop.db'),
port: Number(env.PORT ?? 3000),
sessionSecret: getOrCreateSecret(dataDir),
}
}
function getOrCreateSecret(dataDir: string): string {
const secretPath = path.join(dataDir, 'session-secret')
try {
const existing = readFileSync(secretPath, 'utf8').trim()
if (existing) return existing
} catch {
// first boot — create below
}
const secret = crypto.randomBytes(32).toString('hex')
writeFileSync(secretPath, secret, { mode: 0o600 })
return secret
}

122
server/src/db.ts Normal file
View File

@@ -0,0 +1,122 @@
import Database from 'better-sqlite3'
export type DB = Database.Database
export function openDatabase(path: string): DB {
const db = new Database(path)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
migrate(db)
return db
}
const BASE_SCHEMA = `
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discogs_token TEXT,
subsonic_url TEXT,
subsonic_username TEXT,
subsonic_password TEXT
);
CREATE TABLE IF NOT EXISTS collection_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
discogs_release_id INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
year INTEGER,
formats TEXT NOT NULL DEFAULT '[]',
genres TEXT NOT NULL DEFAULT '[]',
labels TEXT NOT NULL DEFAULT '[]',
tracklist TEXT NOT NULL DEFAULT '[]',
catno TEXT,
country TEXT,
cover_url TEXT,
local_artwork_path TEXT,
barcodes TEXT NOT NULL DEFAULT '[]',
rip_override INTEGER,
date_added TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, discogs_release_id)
);
CREATE TABLE IF NOT EXISTS digital_albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subsonic_id TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
UNIQUE (user_id, subsonic_id)
);
CREATE TABLE IF NOT EXISTS match_links (
user_id INTEGER NOT NULL,
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, item_id)
);
`
function getSchemaVersion(db: DB): number {
const row = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as
| { value: string }
| undefined
return row ? Number(row.value) : 0
}
function setSchemaVersion(db: DB, version: number): void {
db.prepare(
`INSERT INTO app_meta (key, value) VALUES ('schema_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(String(version))
}
/** Upgrade steps for databases created before schema versioning existed. */
export function migrateUpgrades(db: DB): void {
const version = getSchemaVersion(db)
if (version < 2) {
const hasColumn = (db.prepare('PRAGMA table_info(digital_albums)').all() as { name: string }[]).some(
(c) => c.name === 'last_played_at'
)
const apply = db.transaction(() => {
if (!hasColumn) {
db.exec('ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT')
}
db.exec(`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
);`)
setSchemaVersion(db, 2)
})
apply()
}
}
export function migrate(db: DB): void {
db.exec(BASE_SCHEMA)
migrateUpgrades(db)
}

157
server/src/discogs.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { SerialQueue } from './queue.js'
export interface DiscogsReleaseSummary {
id: number
artist: string
title: string
year: number | null
formats: string[]
labels: string[]
country: string | null
catno: string | null
thumbUrl: string | null
}
export interface DiscogsReleaseFull extends DiscogsReleaseSummary {
genres: string[]
tracklist: { position: string; title: string }[]
coverUrl: string | null
barcodes: string[]
}
export class DiscogsError extends Error {
constructor(
public status: number,
message: string
) {
super(message)
this.name = new.target.name
}
}
export class DiscogsAuthError extends DiscogsError {
constructor() {
super(401, 'discogs token rejected')
}
}
export class DiscogsRateLimitError extends DiscogsError {
constructor() {
super(429, 'discogs rate limit hit')
}
}
const USER_AGENT = 'record-shop/0.1.0'
function splitTitle(title: string): { artist: string; title: string } {
const idx = title.indexOf(' - ')
if (idx === -1) return { artist: '', title }
return { artist: title.slice(0, idx), title: title.slice(idx + 3) }
}
export function toYear(year: unknown): number | null {
const n = Number(year)
return Number.isInteger(n) && n > 0 ? n : null
}
export function mapSearchResult(r: any): DiscogsReleaseSummary {
const src = r ?? {}
const { artist, title } = splitTitle(String(src.title ?? ''))
return {
id: Number(src.id),
artist,
title,
year: toYear(src.year),
formats: Array.isArray(src.format) ? src.format.filter((f: any) => f != null).map(String) : [],
labels: Array.isArray(src.label) ? src.label.filter((l: any) => l != null).map(String) : [],
country: src.country ?? null,
catno: src.catno ?? null,
thumbUrl: src.thumb ?? src.cover_image ?? null,
}
}
export function mapRelease(r: any): DiscogsReleaseFull {
const src = r ?? {}
return {
id: Number(src.id),
artist: Array.isArray(src.artists) && src.artists[0] ? String(src.artists[0]?.name ?? '') : '',
title: String(src.title ?? ''),
year: toYear(src.year),
formats: Array.isArray(src.formats) ? src.formats.map((f: any) => String(f?.name ?? '')) : [],
labels: Array.isArray(src.labels) ? src.labels.map((l: any) => String(l?.name ?? '')) : [],
country: src.country ?? null,
catno: Array.isArray(src.labels) && src.labels[0] ? (src.labels[0]?.catno ?? null) : null,
thumbUrl: src.thumb ?? (src.images?.[0]?.uri ?? null),
genres: Array.isArray(src.genres) ? src.genres.filter((g: any) => g != null).map(String) : [],
tracklist: Array.isArray(src.tracklist)
? src.tracklist
.filter((t: any) => t?.title)
.map((t: any) => ({ position: String(t?.position ?? ''), title: String(t?.title ?? '') }))
: [],
coverUrl: src.images?.[0]?.uri ?? null,
barcodes: Array.isArray(src.identifiers)
? src.identifiers
.filter((i: any) => i?.type === 'Barcode' && i?.value)
.map((i: any) => String(i.value))
: [],
}
}
export class DiscogsClient {
private token: string
private fetchImpl: typeof fetch
private baseUrl: string
private queue?: SerialQueue
constructor(token: string, fetchImpl: typeof fetch = fetch, baseUrl = 'https://api.discogs.com', queue?: SerialQueue) {
this.token = token
this.fetchImpl = fetchImpl
this.baseUrl = baseUrl
this.queue = queue
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const doFetch = async (): Promise<any> => {
const url = new URL(`${this.baseUrl}${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
let res: Response
try {
res = await this.fetchImpl(url.toString(), {
headers: {
Authorization: `Discogs token=${this.token}`,
'User-Agent': USER_AGENT,
Accept: 'application/json',
},
})
} catch {
throw new DiscogsError(0, 'could not reach api.discogs.com')
}
if (res.status === 401) throw new DiscogsAuthError()
if (res.status === 429) throw new DiscogsRateLimitError()
if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`)
try {
return await res.json()
} catch {
throw new DiscogsError(res.status, 'discogs returned a non-JSON response')
}
}
return this.queue ? this.queue.run(doFetch) : doFetch()
}
async searchByBarcode(barcode: string): Promise<DiscogsReleaseSummary[]> {
const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' })
const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
}
async searchByText(query: string, format?: string): Promise<DiscogsReleaseSummary[]> {
const params: Record<string, string> = { q: query, type: 'release', per_page: '20' }
if (format) params.format = format
const body = await this.get('/database/search', params)
const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
}
async getRelease(id: number): Promise<DiscogsReleaseFull> {
const body = await this.get(`/releases/${id}`)
return mapRelease(body)
}
}

18
server/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { buildApp } from './app.js'
import { openDatabase } from './db.js'
import { loadConfig } from './config.js'
const config = loadConfig()
const db = openDatabase(config.dbPath)
const app = await buildApp({ db, config })
await app.listen({ port: config.port, host: '0.0.0.0' })
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
void (async () => {
await app.close()
db.close()
process.exit(0)
})()
})
}

43
server/src/matcher.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface Matchable {
title: string
artist: string
}
/**
* Normalization for matching: lowercase, strip accents, strip punctuation,
* strip leading articles (the/a/an anywhere as standalone words), collapse
* whitespace. Spec definition of "normalized artist/title equal".
*/
export function normalize(input: string): string {
return input
.toLowerCase()
.replace(/æ/g, 'ae')
.replace(/œ/g, 'oe')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\b(the|a|an)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function isConfidentMatch(a: Matchable, b: Matchable): boolean {
return normalize(a.title) === normalize(b.title) && normalize(a.artist) === normalize(b.artist)
}
/**
* Albums whose normalized title equals the release's normalized title —
* the "ambiguous" candidate list shown when no confident match exists.
* Candidates with matching artist sort first. Capped at 20.
*/
export function candidateAlbums<T extends Matchable>(release: Matchable, albums: T[]): T[] {
const title = normalize(release.title)
return albums
.filter((a) => normalize(a.title) === title)
.sort((a, b) => {
const am = normalize(a.artist) === normalize(release.artist) ? 0 : 1
const bm = normalize(b.artist) === normalize(release.artist) ? 0 : 1
return am - bm
})
.slice(0, 20)
}

27
server/src/queue.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface SerialQueueOptions {
/** Minimum delay between task starts. 0 = unpaced. */
minIntervalMs?: number
}
export class SerialQueue {
private tail: Promise<unknown> = Promise.resolve()
private lastStart = 0
private minIntervalMs: number
constructor(opts: SerialQueueOptions = {}) {
this.minIntervalMs = opts.minIntervalMs ?? 0
}
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(async () => {
if (this.minIntervalMs > 0) {
const wait = this.lastStart + this.minIntervalMs - Date.now()
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
}
this.lastStart = Date.now()
return fn()
})
this.tail = result.catch(() => {})
return result
}
}

View File

@@ -0,0 +1,31 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { DiscogsReleaseFull } from './discogs.js'
/**
* Disk cache for full Discogs release payloads, keyed by release id.
* Cache read/write failures are best-effort; fetcher errors propagate so
* route-level Discogs error mapping still applies.
*/
export class ReleaseCache {
constructor(private dir: string) {}
async get(id: number, fetcher: () => Promise<DiscogsReleaseFull>): Promise<DiscogsReleaseFull> {
const file = path.join(this.dir, `${id}.json`)
if (existsSync(file)) {
try {
return JSON.parse(readFileSync(file, 'utf8')) as DiscogsReleaseFull
} catch {
// unreadable/corrupt — refetch below
}
}
const release = await fetcher()
try {
mkdirSync(this.dir, { recursive: true })
writeFileSync(file, JSON.stringify(release))
} catch {
// best-effort persistence
}
return release
}
}

98
server/src/ripstatus.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { DB } from './db.js'
import { isConfidentMatch, normalize } from './matcher.js'
export type RipStatus = 'ripped' | 'not_ripped'
/**
* Resolution order per spec:
* 1. manual rip_override (non-null) wins
* 2. else a stored match_link means ripped
* 3. else confident fuzzy match against the user's digital_albums
*/
export function resolveRipStatus(db: DB, userId: number, itemId: number): RipStatus {
const item = db
.prepare('SELECT id, title, artist, rip_override FROM collection_items WHERE id = ? AND user_id = ?')
.get(itemId, userId) as
| { id: number; title: string; artist: string; rip_override: number | null }
| undefined
if (!item) return 'not_ripped'
if (item.rip_override !== null && item.rip_override !== undefined) {
return item.rip_override === 1 ? 'ripped' : 'not_ripped'
}
const link = db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(itemId)
if (link) return 'ripped'
const albums = db
.prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { title: string; artist: string }[]
return albums.some((a) => isConfidentMatch(item, a)) ? 'ripped' : 'not_ripped'
}
/**
* Batch resolution for list views: loads albums and match links once per
* user instead of per item (GET /api/collection is otherwise O(items x albums)).
* Resolution order matches resolveRipStatus.
*/
export function resolveRipStatusBatch(
db: DB,
userId: number,
items: { id: number; title: string; artist: string; rip_override: number | null }[]
): RipStatus[] {
const albums = db
.prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { title: string; artist: string }[]
const albumKeys = new Set(
albums.map((a) => `${normalize(a.title)}|${normalize(a.artist)}`)
)
const linked = new Set(
(
db.prepare('SELECT item_id FROM match_links WHERE user_id = ?').all(userId) as {
item_id: number
}[]
).map((r) => r.item_id)
)
return items.map((item) => {
if (item.rip_override !== null && item.rip_override !== undefined) {
return item.rip_override === 1 ? 'ripped' : 'not_ripped'
}
if (linked.has(item.id)) return 'ripped'
const key = `${normalize(item.title)}|${normalize(item.artist)}`
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
})
}
export interface MatchedAlbum {
id: number
subsonicId: string
lastPlayedAt: string | null
}
/** Album behind an item's rip match — link wins, else confident fuzzy match. Independent of rip_override. */
export function findMatchedAlbum(db: DB, userId: number, itemId: number): MatchedAlbum | null {
const item = db
.prepare('SELECT id, title, artist FROM collection_items WHERE id = ? AND user_id = ?')
.get(itemId, userId) as { id: number; title: string; artist: string } | undefined
if (!item) return null
const link = db
.prepare(
`SELECT da.id, da.subsonic_id, da.last_played_at FROM match_links ml
JOIN digital_albums da ON da.id = ml.album_id WHERE ml.item_id = ?`
)
.get(itemId) as { id: number; subsonic_id: string; last_played_at: string | null } | undefined
if (link) {
return { id: link.id, subsonicId: link.subsonic_id, lastPlayedAt: link.last_played_at }
}
const album = db
.prepare(
`SELECT id, subsonic_id, last_played_at, title, artist FROM digital_albums WHERE user_id = ?`
)
.all(userId) as { id: number; subsonic_id: string; last_played_at: string | null; title: string; artist: string }[]
const match = album.find((a) => isConfidentMatch(item, a))
return match
? { id: match.id, subsonicId: match.subsonic_id, lastPlayedAt: match.last_played_at }
: null
}

View File

@@ -0,0 +1,168 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import {
hashPassword,
verifyPassword,
createUser,
getUserByUsername,
getUserById,
createSession,
getUserBySession,
deleteSession,
type UserRow,
} from '../auth.js'
import argon2 from 'argon2'
export const COOKIE_NAME = 'rs_session'
// Used to equalize response time for unknown usernames (timing-attack defense).
const DUMMY_HASH = argon2.hash('dummy-password-for-timing')
export interface PublicUser {
id: number
username: string
isAdmin: boolean
}
export function toPublicUser(u: { id: number; username: string; is_admin: number }): PublicUser {
return { id: u.id, username: u.username, isAdmin: u.is_admin === 1 }
}
function validateCredentials(username: unknown, password: unknown): string | null {
if (typeof username !== 'string' || username.length < 3 || username.length > 40) {
return 'username must be 3-40 characters'
}
if (typeof password !== 'string' || password.length < 8) {
return 'password must be at least 8 characters'
}
return null
}
export function cookieOpts() {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
maxAge: 30 * 24 * 60 * 60, // seconds
}
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = request.cookies[COOKIE_NAME]
if (token) {
const user = getUserBySession(request.server.db, token)
if (user) {
request.user = user
return
}
}
await reply.code(401).send({ error: 'unauthorized' })
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!request.user || request.user.is_admin !== 1) {
await reply.code(403).send({ error: 'forbidden' })
}
}
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/setup', async (request) => {
const count = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
return { needed: count === 0 }
})
app.post('/api/setup', async (request, reply) => {
const count = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
if (count > 0) return reply.code(403).send({ error: 'setup_already_done' })
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const invalid = validateCredentials(username, password)
if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid })
const passwordHash = await hashPassword(password as string)
const insertSetup = request.server.db.transaction((): 'taken' | { user: UserRow } => {
const current = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
if (current > 0) return 'taken'
const user = createUser(request.server.db, username as string, passwordHash, true)
request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id)
return { user }
})
const result = insertSetup.immediate()
if (result === 'taken') return reply.code(403).send({ error: 'setup_already_done' })
const token = createSession(request.server.db, result.user.id)
return reply
.setCookie(COOKIE_NAME, token, cookieOpts())
.code(200)
.send({ user: toPublicUser(result.user) })
})
app.post('/api/login', async (request, reply) => {
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const user = typeof username === 'string' ? getUserByUsername(request.server.db, username) : undefined
const hash = user?.password_hash ?? (await DUMMY_HASH)
const ok = typeof password === 'string' && (await verifyPassword(hash, password))
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
const oldToken = request.cookies[COOKIE_NAME]
if (oldToken) deleteSession(request.server.db, oldToken)
const token = createSession(request.server.db, user.id)
return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) })
})
app.post('/api/logout', async (request, reply) => {
const token = request.cookies[COOKIE_NAME]
if (token) deleteSession(request.server.db, token)
return reply.clearCookie(COOKIE_NAME, cookieOpts()).code(200).send({ ok: true })
})
app.get('/api/me', { preHandler: [requireAuth] }, async (request) => {
return { user: toPublicUser(request.user as UserRow) }
})
app.get('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
const users = request.server.db.prepare('SELECT * FROM users ORDER BY id').all() as UserRow[]
return { users: users.map(toPublicUser) }
})
app.post('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const invalid = validateCredentials(username, password)
if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid })
if (getUserByUsername(request.server.db, username as string)) {
return reply.code(409).send({ error: 'username_taken' })
}
let user
try {
user = createUser(request.server.db, username as string, await hashPassword(password as string), false)
} catch (err: any) {
if (String(err.message).includes('UNIQUE constraint failed')) {
return reply.code(409).send({ error: 'username_taken' })
}
throw err
}
request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id)
return reply.code(200).send(toPublicUser(user))
})
app.delete(
'/api/users/:id',
{ preHandler: [requireAuth, requireAdmin] },
async (request, reply) => {
const id = Number((request.params as { id: string }).id)
if (id === (request.user as UserRow).id) {
return reply.code(400).send({ error: 'cannot_delete_self' })
}
if (!getUserById(request.server.db, id)) {
return reply.code(404).send({ error: 'not_found' })
}
request.server.db.prepare('DELETE FROM users WHERE id = ?').run(id)
return reply.code(200).send({ ok: true })
}
)
}

View File

@@ -0,0 +1,245 @@
import { FastifyInstance } from 'fastify'
import type { DB } from '../db.js'
import { cacheArtwork } from '../artwork.js'
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
import { requireAuth } from './authRoutes.js'
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
import { getSettings } from './settingsRoutes.js'
interface ItemRow {
id: number
user_id: number
discogs_release_id: number
title: string
artist: string
year: number | null
formats: string
genres: string
labels: string
tracklist: string
catno: string | null
country: string | null
cover_url: string | null
local_artwork_path: string | null
barcodes: string
rip_override: number | null
date_added: string
}
export function rowToItem(db: DB, row: ItemRow, ripStatus?: RipStatus) {
return {
id: row.id,
discogsReleaseId: row.discogs_release_id,
title: row.title,
artist: row.artist,
year: row.year,
formats: JSON.parse(row.formats),
genres: JSON.parse(row.genres),
labels: JSON.parse(row.labels),
tracklist: JSON.parse(row.tracklist),
catno: row.catno,
country: row.country,
artworkUrl: row.local_artwork_path ? `/artwork/${row.local_artwork_path}` : row.cover_url,
barcodes: JSON.parse(row.barcodes),
dateAdded: row.date_added,
ripOverride: row.rip_override === null ? null : row.rip_override === 1,
ripStatus: ripStatus ?? resolveRipStatus(db, row.user_id, row.id),
}
}
function getItem(db: DB, userId: number, id: number): ItemRow | undefined {
return db
.prepare('SELECT * FROM collection_items WHERE id = ? AND user_id = ?')
.get(id, userId) as ItemRow | undefined
}
export async function registerCollectionRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/collection', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const { releaseId, barcode, matchAlbumId } = (request.body ?? {}) as {
releaseId?: number
barcode?: unknown
matchAlbumId?: number
}
if (typeof releaseId !== 'number') return reply.code(400).send({ error: 'invalid_input' })
const db = request.server.db
const userId = request.user!.id
if (matchAlbumId != null) {
const album = db
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
.get(matchAlbumId, userId)
if (!album) return reply.code(404).send({ error: 'album_not_found' })
}
let release
try {
release = await request.server.releaseCache.get(releaseId, () => client.getRelease(releaseId))
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
const barcodeStr = typeof barcode === 'string' ? barcode : undefined
const artworkFile = release.coverUrl
? await cacheArtwork(request.server.config.artworkDir, release.coverUrl, request.server.fetchImpl)
: null
const barcodes = barcodeStr && !release.barcodes.includes(barcodeStr) ? [...release.barcodes, barcodeStr] : release.barcodes
let itemId: number
try {
const info = db
.prepare(
`INSERT INTO collection_items
(user_id, discogs_release_id, title, artist, year, formats, genres, labels, tracklist, catno, country, cover_url, local_artwork_path, barcodes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
userId,
release.id,
release.title,
release.artist,
release.year,
JSON.stringify(release.formats),
JSON.stringify(release.genres),
JSON.stringify(release.labels),
JSON.stringify(release.tracklist),
release.catno,
release.country,
release.coverUrl,
artworkFile,
JSON.stringify(barcodes)
)
itemId = Number(info.lastInsertRowid)
} catch (err: any) {
if (String(err.message).includes('UNIQUE constraint failed')) {
return reply.code(409).send({ error: 'duplicate' })
}
throw err
}
if (matchAlbumId != null) {
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)').run(
userId,
itemId,
matchAlbumId
)
}
const row = getItem(db, userId, itemId) as ItemRow
return reply.code(200).send(rowToItem(db, row))
})
app.get('/api/collection', { preHandler: [requireAuth] }, async (request) => {
const db = request.server.db
const userId = request.user!.id
const rows = db
.prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added DESC, id DESC')
.all(userId) as ItemRow[]
const statuses = resolveRipStatusBatch(db, userId, rows)
const items = rows.map((row, i) => rowToItem(db, row, statuses[i]))
const counts = {
total: items.length,
ripped: items.filter((i) => i.ripStatus === 'ripped').length,
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
}
const { format, ripped, q, onLoan } = request.query as {
format?: string
ripped?: string
q?: string
onLoan?: string
}
let filtered = items
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
if (ripped === 'ripped' || ripped === 'not_ripped') {
filtered = filtered.filter((i) => i.ripStatus === ripped)
}
if (q) {
const needle = q.toLowerCase()
filtered = filtered.filter(
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
)
}
if (onLoan === 'true' || onLoan === 'false') {
const want = onLoan === 'true'
filtered = filtered.filter((i) => {
const has = !!db
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
.get(i.id)
return has === want
})
}
return { items: filtered, counts }
})
app.get('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
if (!row) return reply.code(404).send({ error: 'not_found' })
const loan = db
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
.get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
const matched = findMatchedAlbum(db, request.user!.id, row.id)
const settings = getSettings(db, request.user!.id)
const webUrl =
matched && settings.subsonic_url
? `${settings.subsonic_url.replace(/\/+$/, '')}/app/#/album/${matched.subsonicId}`
: null
return {
...rowToItem(db, row),
matchedAlbum: matched ? { ...matched, webUrl } : null,
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
}
})
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const info = db
.prepare('DELETE FROM collection_items WHERE id = ? AND user_id = ?')
.run(Number((request.params as { id: string }).id), request.user!.id)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
app.patch('/api/collection/:id/rip', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = request.user!.id
const id = Number((request.params as { id: string }).id)
const { ripped } = (request.body ?? {}) as { ripped?: boolean | null }
if (ripped !== true && ripped !== false && ripped !== null) {
return reply.code(400).send({ error: 'invalid_input' })
}
const value = ripped === null ? null : ripped ? 1 : 0
const info = db
.prepare('UPDATE collection_items SET rip_override = ? WHERE id = ? AND user_id = ?')
.run(value, id, userId)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return rowToItem(db, getItem(db, userId, id) as ItemRow)
})
app.post('/api/collection/:id/match', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = request.user!.id
const id = Number((request.params as { id: string }).id)
if (!getItem(db, userId, id)) return reply.code(404).send({ error: 'not_found' })
const { albumId } = (request.body ?? {}) as { albumId?: number | null }
if (albumId === null || albumId === undefined) {
db.prepare('DELETE FROM match_links WHERE user_id = ? AND item_id = ?').run(userId, id)
} else {
const album = db
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
.get(albumId, userId)
if (!album) return reply.code(404).send({ error: 'album_not_found' })
db.prepare(
`INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)
ON CONFLICT(user_id, item_id) DO UPDATE SET album_id = excluded.album_id`
).run(userId, id, albumId)
}
return rowToItem(db, getItem(db, userId, id) as ItemRow)
})
}

View File

@@ -0,0 +1,71 @@
import { FastifyInstance } from 'fastify'
import { existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { requireAuth, requireAdmin } from './authRoutes.js'
function matchLinksFor(db: any, userId: number) {
return db
.prepare(
`SELECT ml.item_id AS itemId, ml.album_id AS albumId FROM match_links ml WHERE ml.user_id = ?`
)
.all(userId)
}
function listBackups(dir: string): { file: string; sizeBytes: number; createdAt: string }[] {
try {
return readdirSync(dir)
.filter((f) => f.endsWith('.db'))
.map((file) => {
const full = path.join(dir, file)
const st = statSync(full)
return { file, sizeBytes: st.size, createdAt: st.mtime.toISOString() }
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
} catch {
return []
}
}
export async function registerDataRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/export', { preHandler: [requireAuth] }, async (request) => {
const db = request.server.db
const userId = request.user!.id
const items = db
.prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added')
.all(userId)
const loans = db.prepare('SELECT * FROM loans WHERE user_id = ?').all(userId)
return {
exportedAt: new Date().toISOString(),
items,
loans,
matchLinks: matchLinksFor(db, userId),
}
})
app.post('/api/backup', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
const config = request.server.config
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19)
// second-granularity stamp: make the destination unique for rapid successive backups
let dest = path.join(config.backupsDir, `record-shop-${stamp}.db`)
let suffix = 1
while (existsSync(dest)) {
dest = path.join(config.backupsDir, `record-shop-${stamp}-${suffix}.db`)
suffix += 1
}
await request.server.db.backup(dest)
// prune to newest 7
const backups = listBackups(config.backupsDir)
for (const old of backups.slice(7)) {
try {
unlinkSync(path.join(config.backupsDir, old.file))
} catch {
// best-effort prune
}
}
return { file: path.basename(dest) }
})
app.get('/api/backups', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
return { backups: listBackups(request.server.config.backupsDir) }
})
}

View File

@@ -0,0 +1,80 @@
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'
export async function registerLibraryRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/library/sync', { preHandler: [requireAuth] }, async (request) => {
return request.server.sync.getState(request.user!.id)
})
app.post('/api/library/sync', { preHandler: [requireAuth] }, async (request, reply) => {
const s = getSettings(request.server.db, request.user!.id)
if (!subsonicConfigComplete(s)) {
return reply.code(409).send({ error: 'no_subsonic_config' })
}
request.server.sync.start(request.user!.id, {
url: s.subsonic_url as string,
username: s.subsonic_username as string,
password: s.subsonic_password as string,
})
return reply.code(202).send(request.server.sync.getState(request.user!.id))
})
app.get('/api/library/albums', { preHandler: [requireAuth] }, async (request) => {
const { q } = request.query as { q?: string }
const userId = request.user!.id
let rows: { id: number; subsonic_id: string; title: string; artist: string }[]
if (q) {
const needle = `%${q}%`
rows = request.server.db
.prepare(
`SELECT id, subsonic_id, title, artist FROM digital_albums
WHERE user_id = ? AND (title LIKE ? OR artist LIKE ?)
ORDER BY artist, title LIMIT 50`
)
.all(userId, needle, needle) as {
id: number
subsonic_id: string
title: string
artist: string
}[]
} else {
rows = request.server.db
.prepare(
`SELECT id, subsonic_id, title, artist FROM digital_albums
WHERE user_id = ? ORDER BY artist, title LIMIT 50`
)
.all(userId) as {
id: number
subsonic_id: string
title: string
artist: string
}[]
}
return {
albums: rows.map((r) => ({ id: r.id, subsonicId: r.subsonic_id, title: r.title, artist: r.artist })),
}
})
// Test-only seeding route so lookup/collection tests can populate digital
// albums without a live Subsonic server. Disabled outside tests.
app.post('/api/library/albums/test-seed', { preHandler: [requireAuth] }, async (request, reply) => {
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
return reply.code(404).send({ error: 'not_found' })
}
const { albums } = (request.body ?? {}) as {
albums?: { subsonicId: string; title: string; artist: string }[]
}
const userId = request.user!.id
const insert = request.server.db.prepare(
`INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist`
)
let inserted = 0
for (const a of albums ?? []) {
insert.run(userId, a.subsonicId, a.title, a.artist)
inserted++
}
return { inserted }
})
}

View File

@@ -0,0 +1,64 @@
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'
interface LoanRow {
id: number
user_id: number
item_id: number
borrower: string
lent_at: string
returned_at: string | null
}
function toLoan(l: LoanRow) {
return { id: l.id, itemId: l.item_id, borrower: l.borrower, lentAt: l.lent_at, returnedAt: l.returned_at }
}
export async function registerLoanRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/collection/:id/loan', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = request.user!.id
const id = Number((request.params as { id: string }).id)
const item = db
.prepare('SELECT id FROM collection_items WHERE id = ? AND user_id = ?')
.get(id, userId)
if (!item) return reply.code(404).send({ error: 'not_found' })
const { borrower } = (request.body ?? {}) as { borrower?: string }
if (typeof borrower !== 'string' || borrower.trim() === '') {
return reply.code(400).send({ error: 'invalid_input', detail: 'borrower is required' })
}
const active = db
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
.get(id)
if (active) return reply.code(409).send({ error: 'already_on_loan' })
const info = db
.prepare('INSERT INTO loans (user_id, item_id, borrower) VALUES (?, ?, ?)')
.run(userId, id, borrower.trim())
const loan = db.prepare('SELECT * FROM loans WHERE id = ?').get(info.lastInsertRowid) as LoanRow
return reply.code(200).send(toLoan(loan))
})
app.get('/api/loans', { preHandler: [requireAuth] }, async (request) => {
const db = request.server.db
const userId = request.user!.id
const active = db
.prepare('SELECT * FROM loans WHERE user_id = ? AND returned_at IS NULL ORDER BY lent_at DESC')
.all(userId) as LoanRow[]
const history = db
.prepare(
'SELECT * FROM loans WHERE user_id = ? AND returned_at IS NOT NULL ORDER BY returned_at DESC LIMIT 50'
)
.all(userId) as LoanRow[]
return { active: active.map(toLoan), history: history.map(toLoan) }
})
app.post('/api/loans/:id/return', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const id = Number((request.params as { id: string }).id)
const info = db
.prepare("UPDATE loans SET returned_at = datetime('now') WHERE id = ? AND user_id = ? AND returned_at IS NULL")
.run(id, request.user!.id)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
}

View File

@@ -0,0 +1,87 @@
import { FastifyInstance, FastifyRequest } from 'fastify'
import {
DiscogsClient,
DiscogsAuthError,
DiscogsRateLimitError,
DiscogsError,
} from '../discogs.js'
import { isConfidentMatch, candidateAlbums } from '../matcher.js'
import { requireAuth } from './authRoutes.js'
import { getSettings } from './settingsRoutes.js'
export function discogsErrorStatus(err: unknown): { code: number; body: Record<string, string> } {
if (err instanceof DiscogsAuthError) return { code: 502, body: { error: 'discogs_auth' } }
if (err instanceof DiscogsRateLimitError) return { code: 429, body: { error: 'discogs_rate_limited' } }
if (err instanceof DiscogsError) return { code: 502, body: { error: 'discogs_error' } }
return { code: 502, body: { error: 'discogs_unreachable' } }
}
export function discogsClientFor(request: FastifyRequest): DiscogsClient | null {
const s = getSettings(request.server.db, request.user!.id)
if (!s.discogs_token) return null
return new DiscogsClient(
s.discogs_token,
request.server.fetchImpl,
'https://api.discogs.com',
request.server.discogsQueue
)
}
export async function registerLookupRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/lookup/barcode/:code', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
try {
const candidates = await client.searchByBarcode((request.params as { code: string }).code)
if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' })
return { candidates }
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
app.get('/api/lookup/search', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const { q, format } = request.query as { q?: string; format?: string }
if (!q) return reply.code(400).send({ error: 'missing_query' })
try {
const candidates = await client.searchByText(q, format || undefined)
if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' })
return { candidates }
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
app.get('/api/lookup/release/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const id = Number((request.params as { id: string }).id)
if (!Number.isInteger(id)) return reply.code(400).send({ error: 'invalid_input' })
try {
const release = await request.server.releaseCache.get(id, () => client.getRelease(id))
const db = request.server.db
const userId = request.user!.id
const duplicate = !!db
.prepare('SELECT id FROM collection_items WHERE user_id = ? AND discogs_release_id = ?')
.get(userId, id)
const albums = db
.prepare('SELECT id, title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { id: number; title: string; artist: string }[]
const confident = albums.some((a) => isConfidentMatch(release, a))
const matchCandidates = confident ? [] : candidateAlbums(release, albums)
return {
release,
duplicate,
ripMatch: confident ? 'ripped' : matchCandidates.length > 0 ? 'ambiguous' : 'not_ripped',
matchCandidates,
}
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
}

View File

@@ -0,0 +1,107 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import type { UserRow } from '../auth.js'
import type { DB } from '../db.js'
export interface SettingsRow {
user_id: number
discogs_token: string | null
subsonic_url: string | null
subsonic_username: string | null
subsonic_password: string | null
}
export function getSettings(db: DB, userId: number): SettingsRow {
const row = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as
| SettingsRow
| undefined
if (row) return row
db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(userId)
return db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as SettingsRow
}
export function settingsView(s: SettingsRow) {
return {
hasDiscogsToken: !!s.discogs_token,
discogsTokenMasked: s.discogs_token
? s.discogs_token.length > 5
? `****${s.discogs_token.slice(-5)}`
: '****'
: null,
subsonicUrl: s.subsonic_url,
subsonicUsername: s.subsonic_username,
hasSubsonicPassword: !!s.subsonic_password,
}
}
export function subsonicConfigComplete(s: SettingsRow): boolean {
return !!(s.subsonic_url && s.subsonic_username && s.subsonic_password)
}
export async function registerSettingsRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/settings', { preHandler: [requireAuth] }, async (request) => {
const s = getSettings(request.server.db, (request.user as UserRow).id)
return settingsView(s)
})
app.put('/api/settings', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = (request.user as UserRow).id
const s = getSettings(db, userId)
const body = (request.body ?? {}) as Record<string, string | undefined>
for (const value of Object.values(body)) {
if (value !== undefined && typeof value !== 'string') {
return reply.code(400).send({ error: 'invalid_input', detail: 'settings fields must be strings' })
}
}
const next = {
discogs_token: body.discogsToken !== undefined ? body.discogsToken || null : s.discogs_token,
subsonic_url: body.subsonicUrl !== undefined ? body.subsonicUrl || null : s.subsonic_url,
subsonic_username:
body.subsonicUsername !== undefined ? body.subsonicUsername || null : s.subsonic_username,
subsonic_password:
body.subsonicPassword !== undefined ? body.subsonicPassword || null : s.subsonic_password,
}
const candidate: SettingsRow = { ...s, ...next }
if (subsonicConfigComplete(candidate)) {
const client = new SubsonicClient({
url: candidate.subsonic_url as string,
username: candidate.subsonic_username as string,
password: candidate.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
try {
await client.ping()
} catch (err: any) {
if (err.code === 'unreachable') {
return reply.code(400).send({ error: 'subsonic_unreachable', detail: err.message })
}
return reply.code(400).send({ error: 'subsonic_auth', detail: err.message })
}
}
db.prepare(
`UPDATE settings SET discogs_token = ?, subsonic_url = ?, subsonic_username = ?, subsonic_password = ?
WHERE user_id = ?`
).run(
next.discogs_token,
next.subsonic_url,
next.subsonic_username,
next.subsonic_password,
userId
)
const updated = getSettings(db, userId)
if (subsonicConfigComplete(updated)) {
request.server.sync.start(userId, {
url: updated.subsonic_url as string,
username: updated.subsonic_username as string,
password: updated.subsonic_password as string,
})
}
return settingsView(updated)
})
}

View File

@@ -0,0 +1,66 @@
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'
import { resolveRipStatusBatch } from '../ripstatus.js'
function countBy(values: string[]): { name: string; count: number }[] {
const map = new Map<string, number>()
for (const v of values) {
if (!v) continue
map.set(v, (map.get(v) ?? 0) + 1)
}
return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
}
export async function registerStatsRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/stats', { preHandler: [requireAuth] }, async (request) => {
const db = request.server.db
const userId = request.user!.id
const items = db
.prepare('SELECT id, title, artist, year, formats, genres, date_added, rip_override FROM collection_items WHERE user_id = ?')
.all(userId) as {
id: number
title: string
artist: string
year: number | null
formats: string
genres: string
date_added: string
rip_override: number | null
}[]
const statuses = resolveRipStatusBatch(db, userId, items)
let ripped = 0
const formats: string[] = []
const genres: string[] = []
const months = new Map<string, number>()
items.forEach((item, i) => {
if (statuses[i] === 'ripped') ripped++
for (const f of JSON.parse(item.formats) as string[]) formats.push(f)
for (const g of JSON.parse(item.genres) as string[]) genres.push(g)
const month = (item.date_added ?? '').slice(0, 7)
if (/^\d{4}-\d{2}$/.test(month)) months.set(month, (months.get(month) ?? 0) + 1)
})
const onLoan = (
db.prepare('SELECT COUNT(*) AS n FROM loans WHERE user_id = ? AND returned_at IS NULL').get(userId) as { n: number }
).n
const addedByMonth: { month: string; count: number }[] = []
const now = new Date()
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
addedByMonth.push({ month: key, count: months.get(key) ?? 0 })
}
return {
totals: { items: items.length, ripped, notRipped: items.length - ripped, onLoan },
ripRatio: items.length === 0 ? 0 : ripped / items.length,
formats: countBy(formats),
decades: countBy(items.map((i) => (i.year ? `${Math.floor(i.year / 10) * 10}s` : ''))),
topGenres: countBy(genres).slice(0, 10),
topArtists: countBy(items.map((i) => i.artist)).slice(0, 10),
addedByMonth,
}
})
}

133
server/src/subsonic.ts Normal file
View File

@@ -0,0 +1,133 @@
import crypto from 'node:crypto'
export interface SubsonicAlbum {
id: string
title: string
artist: string
}
export class SubsonicError extends Error {
constructor(
public code: 'auth' | 'unreachable' | 'api',
message: string
) {
super(message)
}
}
export interface SubsonicClientOptions {
url: string
username: string
password: string
fetchImpl?: typeof fetch
clientName?: string
}
export class SubsonicClient {
private base: string
private username: string
private password: string
private fetchImpl: typeof fetch
private clientName: string
constructor(opts: SubsonicClientOptions) {
this.base = opts.url.replace(/\/+$/, '')
this.username = opts.username
this.password = opts.password
this.fetchImpl = opts.fetchImpl ?? fetch
this.clientName = opts.clientName ?? 'record-shop'
}
private authParams(): Record<string, string> {
const salt = crypto.randomBytes(8).toString('hex')
const token = crypto.createHash('md5').update(this.password + salt).digest('hex')
return { u: this.username, t: token, s: salt, v: '1.16.1', c: this.clientName, f: 'json' }
}
private async request(endpoint: string, params: Record<string, string> = {}): Promise<any> {
let res: Response
try {
const url = new URL(`${this.base}/rest/${endpoint}`)
const search = { ...this.authParams(), ...params }
for (const [k, v] of Object.entries(search)) url.searchParams.set(k, v)
res = await this.fetchImpl(url.toString())
} catch {
throw new SubsonicError('unreachable', `could not reach ${this.base}`)
}
if (!res.ok) throw new SubsonicError('unreachable', `HTTP ${res.status} from ${this.base}`)
let body: any
try {
body = await res.json()
} catch {
throw new SubsonicError('api', 'malformed subsonic response')
}
const envelope = body['subsonic-response']
if (!envelope) throw new SubsonicError('api', 'malformed subsonic response')
if (envelope.status !== 'ok') {
const message: string = envelope.error?.message ?? 'subsonic request failed'
const code = envelope.error?.code === 40 || /credential|auth/i.test(message) ? 'auth' : 'api'
throw new SubsonicError(code, message)
}
return envelope
}
async ping(): Promise<void> {
await this.request('ping')
}
/** Raw endpoint URL with auth params — for streaming passthrough. */
url(endpoint: string, params: Record<string, string> = {}): string {
const u = new URL(`${this.base}/rest/${endpoint}`)
const search = { ...this.authParams(), ...params }
for (const [k, v] of Object.entries(search)) u.searchParams.set(k, v)
return u.toString()
}
async getAlbum(albumId: string): Promise<{ id: string; title: string; artist: string; tracks: { id: string; title: string; duration: number | null; track: number | null }[] }> {
const envelope = await this.request('getAlbum', { id: albumId })
const album = envelope.album ?? {}
const songs: any[] = album.song ?? []
const tracks = songs
.map((s) => ({
id: String(s.id),
title: String(s.title ?? ''),
duration: Number.isFinite(Number(s.duration)) ? Number(s.duration) : null,
track: Number.isInteger(Number(s.track)) ? Number(s.track) : null,
}))
.sort((a, b) => (a.track ?? 9999) - (b.track ?? 9999))
return { id: String(album.id ?? albumId), title: album.name ?? album.title ?? '', artist: album.artist ?? '', tracks }
}
async getRecentAlbums(size = 500): Promise<{ id: string; title: string; artist: string; playedAt: string | null }[]> {
const envelope = await this.request('getAlbumList2', { type: 'recent', size: String(size) })
const list: any[] = envelope.albumList2?.album ?? []
return list.map((a) => ({
id: String(a.id),
title: a.name ?? a.title ?? '',
artist: a.artist ?? '',
playedAt: typeof a.played === 'string' ? a.played : typeof a.playedAt === 'string' ? a.playedAt : null,
}))
}
async getAllAlbums(
onProgress?: (albums: SubsonicAlbum[], done: number) => void
): Promise<SubsonicAlbum[]> {
const all: SubsonicAlbum[] = []
const pageSize = 500
for (let offset = 0; ; offset += pageSize) {
const envelope = await this.request('getAlbumList2', {
type: 'alphabeticalByName',
size: String(pageSize),
offset: String(offset),
})
const list: any[] = envelope.albumList2?.album ?? []
for (const a of list) {
if (!a || a.id == null) continue
all.push({ id: String(a.id), title: a.name ?? a.title ?? '', artist: a.artist ?? '' })
}
onProgress?.(all, all.length)
if (list.length < pageSize || all.length > 20000) break
}
return all
}
}

84
server/src/sync.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { DB } from './db.js'
import { SubsonicClient, SubsonicError } from './subsonic.js'
export interface SyncState {
status: 'idle' | 'running' | 'done' | 'error'
error: string | null
lastSyncedAt: string | null
albums: number
}
export function initialSyncState(): SyncState {
return { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
}
export class SyncManager {
private states = new Map<number, SyncState>()
constructor(
private db: DB,
private fetchImpl: typeof fetch
) {}
getState(userId: number): SyncState {
return this.states.get(userId) ?? initialSyncState()
}
/** Returns false when a sync is already running. */
start(userId: number, config: { url: string; username: string; password: string }): boolean {
const state = this.getState(userId)
if (state.status === 'running') return false
this.states.set(userId, { ...state, status: 'running', error: null })
void this.run(userId, config)
return true
}
private async run(
userId: number,
config: { url: string; username: string; password: string }
): Promise<void> {
const client = new SubsonicClient({ ...config, fetchImpl: this.fetchImpl })
try {
const albums = await client.getAllAlbums()
const seenIds = new Set(albums.map((a) => a.id))
const upsert = this.db.prepare(
`INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist`
)
const selectAll = this.db.prepare('SELECT id, subsonic_id FROM digital_albums WHERE user_id = ?')
const deleteById = this.db.prepare('DELETE FROM digital_albums WHERE id = ?')
const apply = this.db.transaction(() => {
for (const r of albums) upsert.run(userId, r.id, r.title, r.artist)
for (const row of selectAll.all(userId) as { id: number; subsonic_id: string }[]) {
if (!seenIds.has(row.subsonic_id)) deleteById.run(row.id)
}
})
apply()
// best-effort: stamp played timestamps reported by the server
try {
const recent = await client.getRecentAlbums(500)
const stamp = this.db.prepare(
'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
)
for (const r of recent) {
if (r.playedAt) stamp.run(r.playedAt, userId, r.id)
}
} catch {
// recency stamping is optional
}
this.states.set(userId, {
status: 'done',
error: null,
lastSyncedAt: new Date().toISOString(),
albums: albums.length,
})
} catch (err) {
const state = this.getState(userId)
this.states.set(userId, {
...state,
status: 'error',
error: err instanceof SubsonicError ? err.message : 'sync failed',
})
}
}
}

17
server/test/app.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { openDatabase } from '../src/db.js'
import { testConfig } from './helpers.js'
const config = testConfig()
describe('GET /api/health', () => {
it('returns ok', async () => {
const db = openDatabase(':memory:')
const app = await buildApp({ db, config })
const res = await app.inject({ method: 'GET', url: '/api/health' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
await app.close()
})
})

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { cacheArtwork } from '../src/artwork.js'
function tempDir(): string {
return mkdtempSync(path.join(tmpdir(), 'rs-artwork-'))
}
function imageFetch(calls: { count: number }): typeof fetch {
return (async () => {
calls.count++
return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})
}) as typeof fetch
}
describe('cacheArtwork', () => {
it('downloads and stores by url hash with content-type extension', async () => {
const dir = tempDir()
try {
const calls = { count: 0 }
const file = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', imageFetch(calls))
expect(file).toMatch(/^[0-9a-f]{64}\.jpg$/)
expect(existsSync(path.join(dir, file as string))).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('second call for same url does not refetch', async () => {
const dir = tempDir()
try {
const calls = { count: 0 }
const fetcher = imageFetch(calls)
await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher)
const again = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher)
expect(calls.count).toBe(1)
expect(again).not.toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('returns null for empty url and fetch failures without throwing', async () => {
const dir = tempDir()
try {
expect(await cacheArtwork(dir, '', imageFetch({ count: 0 }))).toBeNull()
const failing = (async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/y.jpg', failing)).toBeNull()
const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/y.jpg', notFound)).toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stores non-jpeg types with correct extension', async () => {
const dir = tempDir()
try {
const fetcher = (async () =>
new Response(new Uint8Array([0x89, 0x50]), {
status: 200,
headers: { 'content-type': 'image/png' },
})) as typeof fetch
const file = await cacheArtwork(dir, 'https://img.discogs.com/a.png', fetcher)
expect(file).toMatch(/\.png$/)
expect(readFileSync(path.join(dir, file as string)).length).toBeGreaterThan(0)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('returns null when the response body fails mid-download', async () => {
const dir = tempDir()
try {
const fetcher = (async () =>
new Response(new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([0xff]))
controller.error(new TypeError('network error mid-body'))
},
}), { status: 200, headers: { 'content-type': 'image/jpeg' } })) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/abort.jpg', fetcher)).toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

50
server/test/auth.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import {
hashPassword,
verifyPassword,
createUser,
createSession,
getUserBySession,
deleteSession,
} from '../src/auth.js'
describe('passwords', () => {
it('hashes and verifies', async () => {
const hash = await hashPassword('correct horse battery staple')
expect(hash).not.toContain('correct')
expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true)
expect(await verifyPassword(hash, 'wrong')).toBe(false)
})
})
describe('sessions', () => {
it('creates a user and resolves a valid session, rejects expired', async () => {
const db = openDatabase(':memory:')
const user = createUser(db, 'sam', await hashPassword('password123'), true)
expect(user.is_admin).toBe(1)
const token = await createSession(db, user.id)
const resolved = getUserBySession(db, token, () => Date.now())
expect(resolved?.username).toBe('sam')
const expiredToken = await createSession(db, user.id)
db.prepare('UPDATE sessions SET expires_at = ? WHERE token = ?').run(
'2000-01-01T00:00:00.000Z',
expiredToken
)
expect(getUserBySession(db, expiredToken, () => Date.now())).toBeNull()
expect(
db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number }
).toEqual({ n: 1 })
deleteSession(db, token)
expect(getUserBySession(db, token, () => Date.now())).toBeNull()
})
it('rejects duplicate usernames', async () => {
const db = openDatabase(':memory:')
createUser(db, 'sam', 'hash1', false)
expect(() => createUser(db, 'sam', 'hash2', false)).toThrow()
})
})

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, getCookie, auth, loginAs } from './helpers.js'
describe('auth routes', () => {
it('setup creates admin when no users exist, then is closed', async () => {
const app = await buildTestApp()
const status = await app.inject({ method: 'GET', url: '/api/setup' })
expect(status.json()).toEqual({ needed: true })
const cookie = await setupAdmin(app)
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(me.statusCode).toBe(200)
expect(me.json()).toEqual({ user: { id: 1, username: 'admin', isAdmin: true } })
const again = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'x', password: 'password123' },
})
expect(again.statusCode).toBe(403)
await app.close()
})
it('setup validates input', async () => {
const app = await buildTestApp()
const bad = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'ab', password: 'short' },
})
expect(bad.statusCode).toBe(400)
await app.close()
})
it('login and logout', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const login = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'adminpass123' },
})
expect(login.statusCode).toBe(200)
const cookie = getCookie(login)
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(me.statusCode).toBe(200)
await app.inject({ method: 'POST', url: '/api/logout', ...auth(cookie) })
const after = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(after.statusCode).toBe(401)
await app.close()
})
it('login rejects wrong password', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const res = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'wrongpass123' },
})
expect(res.statusCode).toBe(401)
await app.close()
})
it('protected route requires auth', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/me' })
expect(res.statusCode).toBe(401)
await app.close()
})
it('re-login invalidates the previous session', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const first = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'adminpass123' },
})
const firstCookie = getCookie(first)
const second = await app.inject({
method: 'POST',
url: '/api/login',
...auth(firstCookie),
payload: { username: 'admin', password: 'adminpass123' },
})
expect(second.statusCode).toBe(200)
const oldStale = await app.inject({ method: 'GET', url: '/api/me', ...auth(firstCookie) })
expect(oldStale.statusCode).toBe(401)
const newCookie = getCookie(second)
const fresh = await app.inject({ method: 'GET', url: '/api/me', ...auth(newCookie) })
expect(fresh.statusCode).toBe(200)
await app.close()
})
})
describe('user admin', () => {
async function adminApp() {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
return { app, cookie }
}
it('admin creates a user and lists users', async () => {
const { app, cookie } = await adminApp()
const created = await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
expect(created.statusCode).toBe(200)
expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false })
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob'])
await app.close()
})
it('non-admin cannot list or create users', async () => {
const { app, cookie } = await adminApp()
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) })
expect(list.statusCode).toBe(403)
const create = await app.inject({
method: 'POST',
url: '/api/users',
...auth(bobCookie),
payload: { username: 'eve', password: 'evepass123' },
})
expect(create.statusCode).toBe(403)
await app.close()
})
it('cannot delete self; deleting another user works and cascades settings', async () => {
const { app, cookie } = await adminApp()
await loginAs(app, cookie, 'bob', 'bobpass123')
const settingsCount = () =>
(app.db.prepare('SELECT COUNT(*) AS n FROM settings WHERE user_id = 2').get() as { n: number }).n
const sessionsCount = () =>
(app.db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = 2').get() as { n: number }).n
expect(settingsCount()).toBe(1)
expect(sessionsCount()).toBe(1)
const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) })
expect(selfDelete.statusCode).toBe(400)
const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) })
expect(del.statusCode).toBe(200)
expect(settingsCount()).toBe(0)
expect(sessionsCount()).toBe(0)
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
expect(list.json().users).toHaveLength(1)
await app.close()
})
it('rejects duplicate username', async () => {
const { app, cookie } = await adminApp()
await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
const again = await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'otherpass123' },
})
expect(again.statusCode).toBe(409)
await app.close()
})
})

View File

@@ -0,0 +1,265 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth, getCookie } from './helpers.js'
import { discogsReleaseFixture } from './fixtures.js'
function discogsStub(): typeof fetch {
return (async (input: any) => {
const url = String(input)
if (url.includes('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('img.discogs.com')) {
return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithToken() {
const app = await buildTestApp(discogsStub())
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'testtoken' },
})
return { app, cookie }
}
describe('collection routes', () => {
it('adds a release from discogs, returns item with artwork and rip status', async () => {
const { app, cookie } = await appWithToken()
const res = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001, barcode: '5021592210629' },
})
expect(res.statusCode).toBe(200)
const item = res.json()
expect(item).toMatchObject({
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
catno: 'ZENCD012',
barcodes: ['5021592210629'],
ripOverride: null,
ripStatus: 'not_ripped',
})
expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/)
await app.close()
})
it('rejects duplicate add with 409', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const again = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
expect(again.statusCode).toBe(409)
expect(again.json()).toEqual({ error: 'duplicate' })
await app.close()
})
it('lists items with counts and filters (format, ripped, q)', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 })
expect(all.json().items).toHaveLength(1)
const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) })
expect(cd.json().items).toHaveLength(1)
const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) })
expect(vinyl.json().items).toHaveLength(0)
const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) })
expect(ripped.json().items).toHaveLength(0)
const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) })
expect(notRipped.json().items).toHaveLength(1)
const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) })
expect(q.json().items).toHaveLength(1)
const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) })
expect(qMiss.json().items).toHaveLength(0)
await app.close()
})
it('detail, rip override, match link, re-match search, delete', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
const id = added.json().id as number
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.statusCode).toBe(200)
expect(detail.json().tracklist).toEqual([
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
])
// manual rip override
const rip = await app.inject({
method: 'PATCH',
url: `/api/collection/${id}/rip`,
...auth(cookie),
payload: { ripped: true },
})
expect(rip.json().ripOverride).toBe(true)
expect(rip.json().ripStatus).toBe('ripped')
const clear = await app.inject({
method: 'PATCH',
url: `/api/collection/${id}/rip`,
...auth(cookie),
payload: { ripped: null },
})
expect(clear.json().ripOverride).toBeNull()
expect(clear.json().ripStatus).toBe('not_ripped')
// match link (simulates confirmed ambiguous match)
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] },
})
const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) })
const albumId = albums.json().albums[0].id as number
const linked = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId },
})
expect(linked.json().ripStatus).toBe('ripped')
// clear link
const unlinked = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId: null },
})
expect(unlinked.json().ripStatus).toBe('not_ripped')
const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) })
expect(del.statusCode).toBe(200)
const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(after.statusCode).toBe(404)
await app.close()
})
it('rejects match to another users album', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
const id = added.json().id as number
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] },
})
// album id 99 does not exist for this user
const res = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId: 99 },
})
expect(res.statusCode).toBe(404)
await app.close()
})
it('second user cannot see first users items', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
const bobLogin = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'bob', password: 'bobpass123' },
})
const bobCookie = getCookie(bobLogin)
const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) })
expect(list.json().items).toHaveLength(0)
await app.close()
})
it('detail includes matchedAlbum (null when unmatched)', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const id = added.json().id as number
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.json().matchedAlbum).toBeNull()
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(again.json().matchedAlbum).toMatchObject({
subsonicId: 'alb-9',
webUrl: 'http://navidrome.local/app/#/album/alb-9',
})
await app.close()
})
// KNOWN-RED handoff: the loan route arrives in Task 5 — this test turns
// green then. Everything else in this file must pass now.
it('list supports onLoan=true/false filter', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const id = added.json().id as number
await app.inject({ method: 'POST', url: `/api/collection/${id}/loan`, ...auth(cookie), payload: { borrower: 'Bob' } })
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
expect(all.json().items).toHaveLength(1)
const onLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=true', ...auth(cookie) })
expect(onLoan.json().items).toHaveLength(1)
const notOnLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=false', ...auth(cookie) })
expect(notOnLoan.json().items).toHaveLength(0)
await app.close()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { loadConfig } from '../src/config.js'
function tempDir(): string {
return mkdtempSync(path.join(tmpdir(), 'rs-config-'))
}
describe('loadConfig', () => {
it('creates data + artwork dirs and returns paths', () => {
const dir = tempDir()
try {
const cfg = loadConfig({ DATA_DIR: dir })
expect(cfg.dataDir).toBe(dir)
expect(cfg.dbPath).toBe(path.join(dir, 'record-shop.db'))
expect(cfg.port).toBe(3000)
expect(existsSync(path.join(dir, 'artwork-cache'))).toBe(true)
expect(cfg.sessionSecret).toMatch(/^[0-9a-f]{64}$/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('persists and reuses the session secret', () => {
const dir = tempDir()
try {
const a = loadConfig({ DATA_DIR: dir })
const b = loadConfig({ DATA_DIR: dir })
expect(a.sessionSecret).toBe(b.sessionSecret)
expect(readFileSync(path.join(dir, 'session-secret'), 'utf8')).toBe(a.sessionSecret)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('honours PORT', () => {
const dir = tempDir()
try {
expect(loadConfig({ DATA_DIR: dir, PORT: '8080' }).port).toBe(8080)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

64
server/test/data.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js'
async function appWithData() {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] },
})
return { app, cookie }
}
describe('export', () => {
it('returns per-user data without secrets', async () => {
const { app, cookie } = await appWithData()
await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 'secret-token' } })
const res = await app.inject({ method: 'GET', url: '/api/export', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.exportedAt).toBeTruthy()
expect(body.items).toEqual([])
expect(body.loans).toEqual([])
expect(body.matchLinks).toEqual([])
expect(JSON.stringify(body)).not.toContain('secret-token')
await app.close()
})
})
describe('backups', () => {
it('non-admin cannot trigger backups', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const bob = await loginAs(app, cookie, 'bob', 'bobpass123')
const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(bob) })
expect(res.statusCode).toBe(403)
await app.close()
})
it('admin creates a backup file and lists it', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(res.json().file).toMatch(/record-shop-.*\.db$/)
const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
expect(list.json().backups).toHaveLength(1)
expect(list.json().backups[0].file).toMatch(/\.db$/)
await app.close()
})
it('prunes to the newest 7 backups', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
for (let i = 0; i < 9; i++) {
await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
}
const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
expect(list.json().backups).toHaveLength(7)
await app.close()
})
})

37
server/test/db.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
describe('openDatabase', () => {
it('creates the full schema', () => {
const db = openDatabase(':memory:')
const tables = (
db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as {
name: string
}[]
).map((r) => r.name)
for (const t of [
'users',
'sessions',
'settings',
'collection_items',
'digital_albums',
'match_links',
'app_meta',
]) {
expect(tables).toContain(t)
}
})
it('enforces unique (user_id, discogs_release_id)', () => {
const db = openDatabase(':memory:')
db.prepare(
"INSERT INTO users (username, password_hash, is_admin) VALUES ('sam', 'x', 1)"
).run()
const insert = db.prepare(
`INSERT INTO collection_items (user_id, discogs_release_id, title, artist)
VALUES (1, 100, 'Album', 'Artist')`
)
insert.run()
expect(() => insert.run()).toThrow()
})
})

119
server/test/discogs.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import {
DiscogsClient,
DiscogsAuthError,
DiscogsRateLimitError,
mapSearchResult,
mapRelease,
} from '../src/discogs.js'
import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function stubFetch(routes: (url: string, init?: any) => Response): typeof fetch {
return (async (input: any, init?: any) => routes(String(input), init)) as typeof fetch
}
describe('DiscogsClient', () => {
it('searches by barcode with token and maps results', async () => {
const seen: string[] = []
const seenAuth: string[] = []
const c = new DiscogsClient(
'testtoken',
stubFetch((_url, init) => {
seen.push(_url)
seenAuth.push(String(new Headers(init?.headers).get('Authorization')))
return jsonResponse(discogsSearchFixture)
})
)
const results = await c.searchByBarcode('5021592210629')
expect(seen[0]).toContain('/database/search')
expect(seen[0]).toContain('barcode=5021592210629')
expect(seen[0]).toContain('type=release')
expect(seenAuth[0]).toBe('Discogs token=testtoken')
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD', 'Album'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: 'https://img.discogs.com/small1.jpg',
})
})
it('searches by text with optional format filter', async () => {
const seen: string[] = []
const c = new DiscogsClient(
't',
stubFetch((url) => {
seen.push(url)
return jsonResponse(discogsSearchFixture)
})
)
await c.searchByText('motion', 'Vinyl')
expect(seen[0]).toContain('q=motion')
expect(seen[0]).toContain('format=Vinyl')
})
it('throws DiscogsAuthError on 401', async () => {
const c = new DiscogsClient('bad', stubFetch(() => jsonResponse({ message: 'bad' }, 401)))
await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsAuthError)
})
it('throws DiscogsRateLimitError on 429', async () => {
const c = new DiscogsClient('t', stubFetch(() => jsonResponse({}, 429)))
await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsRateLimitError)
})
it('fetches full release with barcode identifiers', async () => {
const c = new DiscogsClient(
't',
stubFetch((url) => {
expect(url).toContain('/releases/1001')
return jsonResponse(discogsReleaseFixture)
})
)
const release = await c.getRelease(1001)
expect(release.title).toBe('Motion')
expect(release.barcodes).toEqual(['5021592210629'])
expect(release.coverUrl).toBe('https://img.discogs.com/full1.jpg')
})
})
describe('mappers', () => {
it('splits search title into artist/title and parses year', () => {
const first = discogsSearchFixture.results[0]!
const mapped = mapSearchResult(first)
expect(mapped.artist).toBe('The Cinematic Orchestra')
expect(mapped.title).toBe('Motion')
expect(mapped.year).toBe(1999)
})
it('maps full release', () => {
const mapped = mapRelease(discogsReleaseFixture)
expect(mapped.formats).toEqual(['CD'])
expect(mapped.labels).toEqual(['Ninja Tune'])
expect(mapped.tracklist).toEqual([
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
])
})
it('does not throw on malformed payloads', () => {
expect(() => mapSearchResult(null)).not.toThrow()
expect(() => mapRelease(undefined)).not.toThrow()
expect(mapRelease({ tracklist: [null, { title: 'X' }] }).tracklist).toEqual([{ position: '', title: 'X' }])
expect(mapSearchResult({ format: [null, 'CD'] }).formats).toEqual(['CD'])
expect(new DiscogsAuthError().name).toBe('DiscogsAuthError')
expect(new DiscogsRateLimitError().name).toBe('DiscogsRateLimitError')
})
})

45
server/test/fixtures.ts Normal file
View File

@@ -0,0 +1,45 @@
export const discogsSearchFixture = {
results: [
{
id: 1001,
type: 'release',
title: 'The Cinematic Orchestra - Motion',
year: '1999',
format: ['CD', 'Album'],
label: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
cover_image: 'https://img.discogs.com/big1.jpg',
thumb: 'https://img.discogs.com/small1.jpg',
},
{
id: 1002,
type: 'release',
title: 'The Cinematic Orchestra - Motion',
year: '1999',
format: ['Vinyl', '2xLP'],
label: ['Ninja Tune'],
country: 'UK',
catno: 'ZEN012',
cover_image: 'https://img.discogs.com/big2.jpg',
thumb: 'https://img.discogs.com/small2.jpg',
},
],
}
export const discogsReleaseFixture = {
id: 1001,
title: 'Motion',
artists: [{ name: 'The Cinematic Orchestra' }],
year: 1999,
formats: [{ name: 'CD', qty: '1' }],
labels: [{ name: 'Ninja Tune', catno: 'ZENCD012' }],
genres: ['Electronic', 'Jazz'],
country: 'UK',
images: [{ uri: 'https://img.discogs.com/full1.jpg' }],
tracklist: [
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
],
identifiers: [{ type: 'Barcode', value: '5021592210629' }],
}

74
server/test/helpers.ts Normal file
View File

@@ -0,0 +1,74 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { openDatabase, type DB } from '../src/db.js'
import { buildApp } from '../src/app.js'
import type { Config } from '../src/config.js'
import type { FastifyInstance } from 'fastify'
export function testConfig(): Config {
// artworkDir must be a real writable directory (cached images land there)
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
// backupsDir must be a real writable directory (backup files land there)
const backupsDir = mkdtempSync(path.join(tmpdir(), 'rs-bak-'))
// dataDir must be a real writable directory (release-cache payloads land there)
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
return {
dataDir,
artworkDir,
backupsDir,
dbPath: ':memory:',
port: 0,
sessionSecret: 'test-secret-test-secret-test-secret-1234',
}
}
export async function buildTestApp(fetchImpl?: typeof fetch): Promise<FastifyInstance> {
const db = openDatabase(':memory:')
return buildApp({ db, config: testConfig(), fetchImpl })
}
export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise<FastifyInstance> {
return buildApp({ db, config: testConfig(), fetchImpl })
}
/** Runs /api/setup to create admin 'admin' / 'adminpass123'. Returns session token value. */
export async function setupAdmin(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'admin', password: 'adminpass123' },
})
if (res.statusCode !== 200) throw new Error(`setup failed: ${res.body}`)
return getCookie(res)
}
/** Creates and logs in a non-admin user via /api/users + /api/login. Returns session token value. */
export async function loginAs(
app: FastifyInstance,
adminToken: string,
username: string,
password: string
): Promise<string> {
const created = await app.inject({
method: 'POST',
url: '/api/users',
...auth(adminToken),
payload: { username, password },
})
if (created.statusCode !== 200) throw new Error(`user create failed: ${created.body}`)
const login = await app.inject({ method: 'POST', url: '/api/login', payload: { username, password } })
if (login.statusCode !== 200) throw new Error(`login failed: ${login.body}`)
return getCookie(login)
}
export function getCookie(res: { cookies: { name: string; value: string }[] }): string {
const cookie = res.cookies.find((c) => c.name === 'rs_session')
if (!cookie) throw new Error('no rs_session cookie in response')
return cookie.value
}
/** inject option spread for an authenticated request: `app.inject({ ..., ...auth(token) })` */
export function auth(sessionToken: string): { cookies: Record<string, string> } {
return { cookies: { rs_session: sessionToken } }
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
function stubWithRecent(recent: { id: number; name: string; artist: string; played?: string }[]): typeof fetch {
return (async (input: any) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.pathname.endsWith('/rest/getAlbumList2')) {
const type = url.searchParams.get('type')
const body =
type === 'recent'
? { 'subsonic-response': { status: 'ok', albumList2: { album: recent } } }
: { 'subsonic-response': { status: 'ok', albumList2: { album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }] } } }
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function waitForDone(app: any, cookie: string, timeoutMs = 2000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json()
if (state.status !== 'running') return state
await new Promise((r) => setTimeout(r, 10))
}
throw new Error('sync did not finish')
}
describe('last_played_at stamping', () => {
it('sync stamps played timestamps from the recent list', async () => {
const app = await buildTestApp(
stubWithRecent([{ id: 1, name: 'Album 1', artist: 'Artist 0', played: '2026-09-01T10:00:00Z' }])
)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
await waitForDone(app, cookie)
const row = app.db
.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = '1'")
.get() as { last_played_at: string | null }
expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
await app.close()
})
})

206
server/test/library.test.ts Normal file
View File

@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js'
function albumPage(count: number, offset: number) {
return {
'subsonic-response': {
status: 'ok',
albumList2: {
album: Array.from({ length: count }, (_, i) => ({
id: offset + i + 1,
name: `Album ${offset + i + 1}`,
artist: `Artist ${Math.floor((offset + i) / 10)}`,
})),
},
},
}
}
function subsonicStub(): typeof fetch {
return (async (input: any) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.pathname.endsWith('/rest/getAlbumList2')) {
const offset = Number(url.searchParams.get('offset') ?? 0)
return new Response(
JSON.stringify(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithSubsonic() {
const app = await buildTestApp(subsonicStub())
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
return { app, cookie }
}
async function waitForDone(app: any, cookie: string, timeoutMs = 2000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json()
if (state.status !== 'running') return state
await new Promise((r) => setTimeout(r, 10))
}
throw new Error('sync did not finish in time')
}
describe('library sync', () => {
it('sync paginates subsonic and caches albums', async () => {
const { app, cookie } = await appWithSubsonic()
const start = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
expect(start.statusCode).toBe(202)
const state = await waitForDone(app, cookie)
expect(state.status).toBe('done')
expect(state.albums).toBe(503)
expect(state.lastSyncedAt).toBeTruthy()
const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album 7', ...auth(cookie) })
expect(albums.json().albums.length).toBeGreaterThan(0)
// idempotent re-sync: count stays 503 (upsert, no duplicates)
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const second = await waitForDone(app, cookie)
expect(second.albums).toBe(503)
await app.close()
})
it('returns 409 without subsonic config', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
expect(res.statusCode).toBe(409)
expect(res.json()).toEqual({ error: 'no_subsonic_config' })
await app.close()
})
it('reports error state when album fetch fails after valid ping', async () => {
// ping succeeds (settings validation passes) but getAlbumList2 fails (sync errors)
const fetcher = (async (input: any) => {
if (String(input).includes('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const app = await buildTestApp(fetcher)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://flaky.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const state = await waitForDone(app, cookie)
expect(state.status).toBe('error')
expect(state.error).toBeTruthy()
await app.close()
})
it('albums search is scoped per user', async () => {
const { app, cookie } = await appWithSubsonic()
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
await waitForDone(app, cookie)
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
const empty = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album', ...auth(bobCookie) })
expect(empty.json().albums).toHaveLength(0)
await app.close()
})
it('test-seed route inserts albums directly (used by lookup/collection tests)', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
expect(res.statusCode).toBe(200)
expect(res.json().inserted).toBe(1)
await app.close()
})
it('saving subsonic settings triggers a first sync', async () => {
const { app, cookie } = await appWithSubsonic()
const state = await waitForDone(app, cookie)
expect(state.status).toBe('done')
expect(state.albums).toBe(503)
await app.close()
})
it('re-sync removes albums that disappeared from subsonic', async () => {
// sync 1 (from settings PUT): 1 album 'Kept Album'
// sync 2 (explicit POST): 1 album 'Only Album' — 'Kept Album' must be gone
let syncCount = 0
const shrinking = (async (input: any) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.pathname.endsWith('/rest/getAlbumList2')) {
syncCount++
const album =
syncCount === 1
? { id: 1, name: 'Kept Album', artist: 'Artist A' }
: { id: 9, name: 'Only Album', artist: 'Artist B' }
return new Response(
JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [album] } } }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
return new Response('nope', { status: 404 })
}) as typeof fetch
const app = await buildTestApp(shrinking)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
const first = await waitForDone(app, cookie)
expect(first.albums).toBe(1)
expect(await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) }).then((r) => r.json())).toMatchObject({ albums: [expect.objectContaining({ title: 'Kept Album' })] })
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const second = await waitForDone(app, cookie)
expect(second.albums).toBe(1)
const stale = await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) })
expect(stale.json().albums).toHaveLength(0) // stale album removed
const kept = await app.inject({ method: 'GET', url: '/api/library/albums?q=Only', ...auth(cookie) })
expect(kept.json().albums).toHaveLength(1)
await app.close()
})
})

94
server/test/loans.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
import { discogsReleaseFixture } from './fixtures.js'
const discogsStub = (async (input: any) =>
String(input).includes('/releases/1001')
? new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
: new Response('nope', { status: 404 })) as typeof fetch
async function appWithItem() {
const app = await buildTestApp(discogsStub)
const cookie = await setupAdmin(app)
await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 't' } })
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
return { app, cookie, itemId: added.json().id as number }
}
describe('loans', () => {
it('lend, list active, return', async () => {
const { app, cookie, itemId } = await appWithItem()
const lend = await app.inject({
method: 'POST',
url: `/api/collection/${itemId}/loan`,
...auth(cookie),
payload: { borrower: 'Bob' },
})
expect(lend.statusCode).toBe(200)
const loan = lend.json()
expect(loan).toMatchObject({ itemId, borrower: 'Bob', returnedAt: null })
const dup = await app.inject({
method: 'POST',
url: `/api/collection/${itemId}/loan`,
...auth(cookie),
payload: { borrower: 'Eve' },
})
expect(dup.statusCode).toBe(409)
expect(dup.json()).toEqual({ error: 'already_on_loan' })
const list = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
expect(list.json().active).toHaveLength(1)
expect(list.json().history).toHaveLength(0)
const ret = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
expect(ret.statusCode).toBe(200)
const after = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
expect(after.json().active).toHaveLength(0)
expect(after.json().history).toHaveLength(1)
const again = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
expect(again.statusCode).toBe(404)
await app.close()
})
it('detail route reports the active loan, null before lending', async () => {
const { app, cookie, itemId } = await appWithItem()
const before = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
expect(before.json().loan).toBeNull()
await app.inject({
method: 'POST',
url: `/api/collection/${itemId}/loan`,
...auth(cookie),
payload: { borrower: 'Bob' },
})
const after = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
expect(after.json().loan).toMatchObject({ borrower: 'Bob' })
await app.close()
})
it('validates borrower and ownership', async () => {
const { app, cookie, itemId } = await appWithItem()
const empty = await app.inject({
method: 'POST',
url: `/api/collection/${itemId}/loan`,
...auth(cookie),
payload: { borrower: ' ' },
})
expect(empty.statusCode).toBe(400)
const missing = await app.inject({
method: 'POST',
url: `/api/collection/9999/loan`,
...auth(cookie),
payload: { borrower: 'Bob' },
})
expect(missing.statusCode).toBe(404)
await app.close()
})
})

193
server/test/lookup.test.ts Normal file
View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js'
function stubFetch(routes: (url: string) => Response): typeof fetch {
return (async (input: any) => routes(String(input))) as typeof fetch
}
function discogsStub(): typeof fetch {
return stubFetch((url) => {
if (url.includes('/database/search')) {
return new Response(JSON.stringify(discogsSearchFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('nope', { status: 404 })
})
}
async function appWithToken(discogsFetch: typeof fetch) {
const app = await buildTestApp(discogsFetch)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'testtoken' },
})
return { app, cookie }
}
describe('GET /api/lookup/barcode/:code', () => {
it('returns candidates on match', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/5021592210629', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.candidates).toHaveLength(2)
expect(body.candidates[0]).toMatchObject({
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
})
await app.close()
})
it('returns 404 not_found when discogs has zero results', async () => {
const empty = stubFetch((url) =>
url.includes('/database/search')
? new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
: new Response('nope', { status: 404 })
)
const { app, cookie } = await appWithToken(empty)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
it('returns 409 no_discogs_token when token unset', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(409)
expect(res.json()).toEqual({ error: 'no_discogs_token' })
await app.close()
})
it('maps discogs auth failure to 502 discogs_auth', async () => {
const unauthorized = stubFetch(() => new Response('bad token', { status: 401 }))
const { app, cookie } = await appWithToken(unauthorized)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'discogs_auth' })
await app.close()
})
})
describe('GET /api/lookup/search', () => {
it('returns candidates for text query with format filter', async () => {
const seen: string[] = []
const { app, cookie } = await appWithToken(
stubFetch((url) => {
seen.push(url)
return new Response(JSON.stringify(discogsSearchFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
)
const res = await app.inject({ method: 'GET', url: '/api/lookup/search?q=motion&format=Vinyl', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(seen[0]).toContain('q=motion')
expect(seen[0]).toContain('format=Vinyl')
expect(res.json().candidates).toHaveLength(2)
await app.close()
})
})
describe('GET /api/lookup/release/:id', () => {
it('returns full release with duplicate and ripMatch', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.release).toMatchObject({
id: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
barcodes: ['5021592210629'],
})
expect(body.duplicate).toBe(false)
expect(body.ripMatch).toBe('not_ripped')
expect(body.matchCandidates).toEqual([])
await app.close()
})
it('caches release payloads on disk (second lookup costs no discogs call)', async () => {
let releaseCalls = 0
const { app, cookie } = await appWithToken(
stubFetch((url) => {
if (url.includes('/releases/1001')) {
releaseCalls++
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('nope', { status: 404 })
})
)
await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(releaseCalls).toBe(1)
await app.close()
})
it('rejects non-integer release ids with 400', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/abc', ...auth(cookie) })
expect(res.statusCode).toBe(400)
expect(res.json()).toEqual({ error: 'invalid_input' })
await app.close()
})
it('reports duplicate when release already in collection', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.json().duplicate).toBe(true)
await app.close()
})
it('reports ripped on confident match against digital albums', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.json().ripMatch).toBe('ripped')
await app.close()
})
it('reports ambiguous with matchCandidates on same-title different-artist', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'Somebody Else' }] },
})
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
const body = res.json()
expect(body.ripMatch).toBe('ambiguous')
expect(body.matchCandidates).toHaveLength(1)
expect(body.matchCandidates[0]).toMatchObject({ title: 'Motion', artist: 'Somebody Else' })
await app.close()
})
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { normalize, isConfidentMatch, candidateAlbums } from '../src/matcher.js'
describe('normalize', () => {
it('lowercases, strips punctuation, articles, accents, extra whitespace', () => {
expect(normalize('The Cinematic Orchestra!')).toBe('cinematic orchestra')
expect(normalize(' Blur: The Best of… ')).toBe('blur best of')
expect(normalize('Björk — Début')).toBe('bjork debut')
expect(normalize('A Tribe Called Quest')).toBe('tribe called quest')
expect(normalize('Ænima')).toBe('aenima')
})
})
describe('isConfidentMatch', () => {
it('matches when normalized artist AND title are equal', () => {
expect(
isConfidentMatch(
{ title: 'Motion!', artist: 'The Cinematic Orchestra' },
{ title: 'Motion', artist: 'Cinematic Orchestra' }
)
).toBe(true)
expect(
isConfidentMatch(
{ title: 'Motion', artist: 'Massive Attack' },
{ title: 'Motion', artist: 'The Cinematic Orchestra' }
)
).toBe(false)
expect(
isConfidentMatch(
{ title: 'Blue Lines', artist: 'Massive Attack' },
{ title: 'Motion', artist: 'Massive Attack' }
)
).toBe(false)
})
})
describe('candidateAlbums', () => {
const albums = [
{ id: 1, title: 'Motion', artist: 'Someone Else' },
{ id: 2, title: 'Motion', artist: 'The Cinematic Orchestra' },
{ id: 3, title: 'Other Album', artist: 'The Cinematic Orchestra' },
]
it('returns same-title albums with artist matches first', () => {
const candidates = candidateAlbums(
{ title: 'Motion', artist: 'Cinematic Orchestra' },
albums
)
expect(candidates.map((c) => c.id)).toEqual([2, 1])
})
it('caps candidates at 20', () => {
const many = Array.from({ length: 50 }, (_, i) => ({
id: i,
title: 'Motion',
artist: `Artist ${i}`,
}))
expect(candidateAlbums({ title: 'Motion', artist: 'zzz' }, many)).toHaveLength(20)
})
})

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import Database from 'better-sqlite3'
import { openDatabase, migrateUpgrades } from '../src/db.js'
/** Builds a pre-v2 database (plan-1 schema, no version row, no new columns). */
function legacyDb(): Database.Database {
const db = new Database(':memory:')
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')));
CREATE TABLE sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL);
CREATE TABLE settings (user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discogs_token TEXT, subsonic_url TEXT, subsonic_username TEXT, subsonic_password TEXT);
CREATE TABLE collection_items (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, discogs_release_id INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, year INTEGER, formats TEXT NOT NULL DEFAULT '[]', genres TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', tracklist TEXT NOT NULL DEFAULT '[]', catno TEXT, country TEXT, cover_url TEXT, local_artwork_path TEXT, barcodes TEXT NOT NULL DEFAULT '[]', rip_override INTEGER, date_added TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, discogs_release_id));
CREATE TABLE digital_albums (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subsonic_id TEXT NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, UNIQUE (user_id, subsonic_id));
CREATE TABLE match_links (user_id INTEGER NOT NULL, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id));
INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1);
INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (1, 1, 'a1', 'Motion', 'The Cinematic Orchestra');
`)
return db
}
function columns(db: Database.Database, table: string): string[] {
return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name)
}
describe('schema migration v2', () => {
it('migrates a legacy database: loans table, last_played_at, version row', () => {
const db = legacyDb()
migrateUpgrades(db)
expect(columns(db, 'digital_albums')).toContain('last_played_at')
expect(columns(db, 'loans')).toEqual([
'id',
'user_id',
'item_id',
'borrower',
'lent_at',
'returned_at',
])
const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
expect(version).toEqual({ value: '2' })
// existing data survives
expect(db.prepare('SELECT title FROM digital_albums').get()).toEqual({ title: 'Motion' })
})
it('openDatabase creates a fresh v2 database directly', () => {
const db = openDatabase(':memory:')
expect(columns(db, 'digital_albums')).toContain('last_played_at')
expect(columns(db, 'loans')).toContain('borrower')
const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
expect(version).toEqual({ value: '2' })
})
it('migrations are idempotent', () => {
const db = openDatabase(':memory:')
expect(() => migrateUpgrades(db)).not.toThrow()
expect(
(db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as { value: string }).value
).toBe('2')
})
})

42
server/test/queue.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest'
import { SerialQueue } from '../src/queue.js'
describe('SerialQueue', () => {
it('runs tasks strictly in order even when they resolve out of order', async () => {
const q = new SerialQueue()
const order: number[] = []
const slow = () => new Promise((r) => setTimeout(r, 20)).then(() => order.push(1))
const fast = () => Promise.resolve().then(() => order.push(2))
await Promise.all([q.run(slow), q.run(fast)])
expect(order).toEqual([1, 2])
})
it('continues after a failing task', async () => {
const q = new SerialQueue()
await expect(
q.run(async () => {
throw new Error('boom')
})
).rejects.toThrow('boom')
const result = await q.run(async () => 'ok')
expect(result).toBe('ok')
})
it('paces call starts at least minIntervalMs apart', async () => {
vi.useFakeTimers()
const q = new SerialQueue({ minIntervalMs: 1000 })
let t1 = 0
let t2 = 0
const p1 = q.run(async () => {
t1 = Date.now()
})
const p2 = q.run(async () => {
t2 = Date.now()
})
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
await Promise.all([p1, p2])
expect(t2 - t1).toBeGreaterThanOrEqual(1000)
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { openDatabase, type DB } from '../src/db.js'
import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js'
describe('resolveRipStatus', () => {
let db: DB
let itemId: number
beforeEach(() => {
db = openDatabase(':memory:')
db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run()
db.prepare(
"INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')"
).run()
itemId = 10
})
function addAlbum(id: number, title: string, artist: string) {
db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run(
id,
`sub-${id}`,
title,
artist
)
}
it('no album and no override → not_ripped', () => {
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
})
it('confident auto-match → ripped', () => {
addAlbum(1, 'Motion!', 'Cinematic Orchestra')
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
it('same title different artist → not_ripped', () => {
addAlbum(1, 'Motion', 'Massive Attack')
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
})
it('match link → ripped even without fuzzy match', () => {
addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
it('manual override wins over everything (both directions)', () => {
addAlbum(1, 'Motion', 'The Cinematic Orchestra')
db.prepare('UPDATE collection_items SET rip_override = 0 WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
db.prepare('UPDATE collection_items SET rip_override = 1 WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
db.prepare('UPDATE collection_items SET rip_override = NULL WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
})
describe('findMatchedAlbum', () => {
let db: DB
beforeEach(() => {
db = openDatabase(':memory:')
db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run()
db.prepare(
"INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')"
).run()
})
function addAlbum(id: number, title: string, artist: string) {
db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run(
id,
`sub-${id}`,
title,
artist
)
}
it('returns the match-linked album', () => {
addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
const m = findMatchedAlbum(db, 1, 10)
expect(m).toMatchObject({ subsonicId: 'sub-1', lastPlayedAt: null })
})
it('falls back to the confident fuzzy match', () => {
addAlbum(2, 'Motion!', 'Cinematic Orchestra')
const m = findMatchedAlbum(db, 1, 10)
expect(m).toMatchObject({ subsonicId: 'sub-2' })
})
it('returns null when nothing matches or the item is missing', () => {
expect(findMatchedAlbum(db, 1, 10)).toBeNull()
expect(findMatchedAlbum(db, 1, 9999)).toBeNull()
})
})

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
function subsonicStubFetch(ok: boolean): typeof fetch {
return (async (url: any) => {
if (String(url).includes('/rest/ping')) {
const body = ok
? { 'subsonic-response': { status: 'ok' } }
: {
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password.' },
},
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('not found', { status: 404 })
}) as typeof fetch
}
describe('settings routes', () => {
it('starts empty and stores/clears tokens', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const empty = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(empty.json()).toEqual({
hasDiscogsToken: false,
discogsTokenMasked: null,
subsonicUrl: null,
subsonicUsername: null,
hasSubsonicPassword: false,
})
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'abcdef0123456789' },
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ hasDiscogsToken: true, discogsTokenMasked: '****56789' })
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: '' },
})
const cleared = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(cleared.json().hasDiscogsToken).toBe(false)
await app.close()
})
it('stores valid subsonic config after successful ping', async () => {
const app = await buildTestApp(subsonicStubFetch(true))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({
subsonicUrl: 'http://navidrome.local',
hasSubsonicPassword: true,
})
await app.close()
})
it('rejects bad subsonic credentials with 400 subsonic_auth', async () => {
const app = await buildTestApp(subsonicStubFetch(false))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'wrong',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_auth' })
await app.close()
})
it('rejects unreachable subsonic with 400 subsonic_unreachable', async () => {
const failing = (async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const app = await buildTestApp(failing)
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://nope.invalid',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_unreachable' })
await app.close()
})
it('requires auth', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/settings' })
expect(res.statusCode).toBe(401)
await app.close()
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { testConfig } from './helpers.js'
import { openDatabase } from '../src/db.js'
import { buildApp } from '../src/app.js'
describe('static serving', () => {
let dir: string
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), 'rs-static-'))
mkdirSync(path.join(dir, 'web', 'dist'), { recursive: true })
mkdirSync(path.join(dir, 'artwork'), { recursive: true })
writeFileSync(path.join(dir, 'web', 'dist', 'index.html'), '<html>record-shop</html>')
writeFileSync(path.join(dir, 'artwork', 'abc.jpg'), 'fakejpeg')
})
afterAll(() => {
rmSync(dir, { recursive: true, force: true })
})
async function build() {
const config = { ...testConfig(), artworkDir: path.join(dir, 'artwork') }
const app = await buildApp({
db: openDatabase(':memory:'),
config,
webDist: path.join(dir, 'web', 'dist'),
})
return app
}
it('serves index.html at / and SPA-falls back for client routes', async () => {
const app = await build()
const root = await app.inject({ method: 'GET', url: '/' })
expect(root.statusCode).toBe(200)
expect(root.body).toContain('record-shop')
const spa = await app.inject({ method: 'GET', url: '/library' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('record-shop')
await app.close()
})
it('serves artwork files', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/artwork/abc.jpg' })
expect(res.statusCode).toBe(200)
expect(res.body).toBe('fakejpeg')
await app.close()
})
it('unknown artwork paths return json 404, not the SPA', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/artwork/missing.jpg' })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
it('unknown api routes return json 404, not the SPA', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/api/nope' })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
})

47
server/test/stats.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js'
function seedItems(db: ReturnType<typeof openDatabase>) {
const ins = db.prepare(
`INSERT INTO collection_items (user_id, discogs_release_id, title, artist, year, formats, genres, date_added, rip_override)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)`
)
ins.run(1, 'Motion', 'The Cinematic Orchestra', 1999, JSON.stringify(['CD']), JSON.stringify(['Electronic']), '2026-08-01 10:00:00', null)
ins.run(2, 'Blue Lines', 'Massive Attack', 1991, JSON.stringify(['Vinyl']), JSON.stringify(['Electronic']), '2026-08-15 10:00:00', 1)
ins.run(3, 'Mezzanine', 'Massive Attack', 1998, JSON.stringify(['CD']), JSON.stringify(['Downtempo', 'Electronic']), '2026-08-20 10:00:00', null)
ins.run(4, 'Dummy', 'Portishead', 1994, JSON.stringify(['CD', 'Album']), JSON.stringify(['Downtempo']), '2025-09-01 10:00:00', null)
db.prepare("INSERT INTO loans (user_id, item_id, borrower) VALUES (1, 2, 'Bob')").run()
}
describe('GET /api/stats', () => {
it('aggregates totals, formats, genres, artists, months, ratio, loans', async () => {
const db = openDatabase(':memory:')
const app = await buildTestAppWithDb(db)
const cookie = await setupAdmin(app)
// seed after setup so user_id 1 (admin) exists and FKs hold
seedItems(db)
const res = await app.inject({ method: 'GET', url: '/api/stats', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const s = res.json()
// only Blue Lines has rip_override=1; no digital albums/match_links → ripped=1, notRipped=3
expect(s.totals).toEqual({ items: 4, ripped: 1, notRipped: 3, onLoan: 1 })
expect(s.ripRatio).toBeCloseTo(0.25)
expect(s.formats).toEqual([
{ name: 'CD', count: 3 },
{ name: 'Vinyl', count: 1 },
{ name: 'Album', count: 1 },
])
expect(s.topGenres.slice(0, 2)).toEqual([
{ name: 'Electronic', count: 3 },
{ name: 'Downtempo', count: 2 },
])
// Massive Attack (2) leads; Cinematic Orchestra and Portishead both 1 → assert lead + total only
expect(s.topArtists).toHaveLength(3)
expect(s.topArtists[0]).toEqual({ name: 'Massive Attack', count: 2 })
// 2026-08 items: Motion, Blue Lines, Mezzanine (Dummy 2025-09 is outside the 12-month window)
const aug = s.addedByMonth.find((m: { month: string }) => m.month === '2026-08')
expect(aug).toEqual({ month: '2026-08', count: 3 })
await app.close()
})
})

View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest'
import { SubsonicClient, SubsonicError } from '../src/subsonic.js'
function subsonicResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function albumPage(count: number, offset: number) {
return {
'subsonic-response': {
status: 'ok',
albumList2: {
album: Array.from({ length: count }, (_, i) => ({
id: offset + i + 1,
name: `Album ${offset + i + 1}`,
artist: `Artist ${Math.floor((offset + i) / 10)}`,
})),
},
},
}
}
describe('SubsonicClient', () => {
it('ping sends auth params and succeeds', async () => {
const seen: string[] = []
const c = new SubsonicClient({
url: 'http://navidrome.local',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
seen.push(String(input))
return subsonicResponse({ 'subsonic-response': { status: 'ok' } })
}) as typeof fetch,
})
await c.ping()
expect(seen[0]!).toContain('/rest/ping')
expect(seen[0]!).toContain('u=sam')
expect(seen[0]!).toContain('v=1.16.1')
expect(seen[0]!).toContain('c=record-shop')
expect(seen[0]!).toContain('f=json')
expect(seen[0]!).toMatch(/t=[0-9a-f]{32}/)
})
it('ping raises auth error on failed status with code 40', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'bad',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password.' },
},
})) as typeof fetch,
})
const err = await c.ping().catch((e) => e)
expect(err).toBeInstanceOf(SubsonicError)
expect((err as SubsonicError).code).toBe('auth')
})
it('getAllAlbums paginates until a short page', async () => {
const seen: string[] = []
const c = new SubsonicClient({
url: 'http://navidrome.local',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
const url = new URL(String(input))
seen.push(url.searchParams.get('offset') ?? '')
const offset = Number(url.searchParams.get('offset') ?? 0)
// first page: 500 albums, second: 3, third never requested
return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500))
}) as typeof fetch,
})
const albums = await c.getAllAlbums()
expect(seen).toEqual(['0', '500'])
expect(albums).toHaveLength(503)
expect(albums[0]!).toEqual({ id: '1', title: 'Album 1', artist: 'Artist 0' })
})
it('getAllAlbums reports progress', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
const offset = Number(new URL(String(input)).searchParams.get('offset') ?? 0)
return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500))
}) as typeof fetch,
})
const progress: number[] = []
await c.getAllAlbums((_albums, done) => progress.push(done))
expect(progress[progress.length - 1]!).toBe(503)
})
it('getAllAlbums skips null album entries', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'ok',
albumList2: {
album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }, null, { id: 2, name: 'Album 2', artist: 'Artist 0' }],
},
},
})) as typeof fetch,
})
const albums = await c.getAllAlbums()
expect(albums).toEqual([
{ id: '1', title: 'Album 1', artist: 'Artist 0' },
{ id: '2', title: 'Album 2', artist: 'Artist 0' },
])
})
it('url() builds a raw endpoint URL with auth params', () => {
const c = new SubsonicClient({
url: 'http://navidrome.local',
username: 'sam',
password: 'pass',
fetchImpl: (async () => subsonicResponse({})) as typeof fetch,
})
const u = new URL(c.url('stream', { id: 'song-9' }))
expect(u.pathname).toBe('/rest/stream')
expect(u.searchParams.get('id')).toBe('song-9')
expect(u.searchParams.get('u')).toBe('sam')
expect(u.searchParams.get('f')).toBe('json')
})
it('getAlbum returns ordered tracks', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'ok',
album: {
id: 'alb-1',
name: 'Motion',
artist: 'The Cinematic Orchestra',
song: [
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
],
},
},
})) as typeof fetch,
})
const album = await c.getAlbum('alb-1')
expect(album).toEqual({
id: 'alb-1',
title: 'Motion',
artist: 'The Cinematic Orchestra',
tracks: [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
],
})
})
it('getRecentAlbums returns the recent list raw', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'ok',
albumList2: {
album: [
{ id: 'a1', name: 'Motion', artist: 'TCO', played: '2026-09-01T10:00:00Z' },
{ id: 'a2', name: 'Blue Lines', artist: 'Massive Attack' },
],
},
},
})) as typeof fetch,
})
const recent = await c.getRecentAlbums(500)
expect(recent).toEqual([
{ id: 'a1', title: 'Motion', artist: 'TCO', playedAt: '2026-09-01T10:00:00Z' },
{ id: 'a2', title: 'Blue Lines', artist: 'Massive Attack', playedAt: null },
])
})
})

View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*"]
}

5
server/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*", "test/**/*"]
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
}
}

23
vitest.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
test: {
name: 'server',
include: ['server/test/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'web',
include: ['web/test/**/*.test.{ts,tsx}'],
environment: 'jsdom',
setupFiles: ['web/test/setup.ts'],
},
},
],
},
})

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>record-shop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7
web/public/icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a0a0a"/>
<circle cx="256" cy="256" r="160" fill="#171717" stroke="#34d399" stroke-width="16"/>
<circle cx="256" cy="256" r="96" fill="none" stroke="#262626" stroke-width="8"/>
<circle cx="256" cy="256" r="40" fill="#34d399"/>
<circle cx="256" cy="256" r="12" fill="#0a0a0a"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@@ -0,0 +1,13 @@
{
"name": "record-shop",
"short_name": "record-shop",
"description": "Track your physical music collection",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
]
}

11
web/public/sw.js Normal file
View File

@@ -0,0 +1,11 @@
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', () => {
// no-op: presence of a fetch handler enables PWA install
})

52
web/src/App.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import type { ReactNode } from 'react'
import { AuthProvider, useAuth } from './auth'
import Shell from './shell'
import SetupPage from './pages/SetupPage'
import LoginPage from './pages/LoginPage'
import LibraryPage from './pages/LibraryPage'
import ItemPage from './pages/ItemPage'
import ScanPage from './pages/ScanPage'
import AddPage from './pages/AddPage'
import SettingsPage from './pages/SettingsPage'
import StatsPage from './pages/StatsPage'
import QueuePage from './pages/QueuePage'
function Gate({ children }: { children: ReactNode }) {
const { status } = useAuth()
if (status === 'loading') {
return <div className="min-h-dvh bg-neutral-950" aria-busy="true" />
}
if (status === 'setup') return <Navigate to="/setup" replace />
if (status === 'unauthenticated') return <Navigate to="/login" replace />
return <>{children}</>
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
element={
<Gate>
<Shell />
</Gate>
}
>
<Route path="/library" element={<LibraryPage />} />
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/scan" element={<ScanPage />} />
<Route path="/add" element={<AddPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/queue" element={<QueuePage />} />
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
)
}

117
web/src/api.ts Normal file
View File

@@ -0,0 +1,117 @@
import type {
BackupFile,
Candidate,
CollectionResponse,
DigitalAlbum,
Item,
ItemDetail,
Loan,
LoansResponse,
ReleasePreview,
SettingsView,
Stats,
SyncState,
User,
} from './types'
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
public detail?: string
) {
super(detail ? `${code}: ${detail}` : code)
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { Accept: 'application/json' },
...init,
})
if (!res.ok) {
let code = 'unknown_error'
let detail: string | undefined
try {
const body = (await res.json()) as { error?: string; detail?: string }
code = body.error ?? code
detail = body.detail
} catch {
// non-JSON error body
}
throw new ApiError(res.status, code, detail)
}
return (await res.json()) as T
}
function post<T>(path: string, payload?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
...(payload !== undefined
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
: {}),
})
}
export const api = {
setupStatus: () => request<{ needed: boolean }>('/api/setup'),
setup: (username: string, password: string) => post<{ user: User }>('/api/setup', { username, password }),
login: (username: string, password: string) => post<{ user: User }>('/api/login', { username, password }),
logout: () => post<{ ok: boolean }>('/api/logout'),
me: () => request<{ user: User }>('/api/me'),
listUsers: () => request<{ users: User[] }>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }),
getSettings: () => request<SettingsView>('/api/settings'),
putSettings: (payload: Partial<Record<'discogsToken' | 'subsonicUrl' | 'subsonicUsername' | 'subsonicPassword', string>>) =>
request<SettingsView>('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
lookupBarcode: (code: string) => request<{ candidates: Candidate[] }>(`/api/lookup/barcode/${encodeURIComponent(code)}`),
lookupSearch: (q: string, format?: string) =>
request<{ candidates: Candidate[] }>(
`/api/lookup/search?q=${encodeURIComponent(q)}${format ? `&format=${encodeURIComponent(format)}` : ''}`
),
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
listCollection: (params: { format?: string; ripped?: string; q?: string; onLoan?: string } = {}) => {
const usp = new URLSearchParams()
if (params.format) usp.set('format', params.format)
if (params.ripped) usp.set('ripped', params.ripped)
if (params.q) usp.set('q', params.q)
if (params.onLoan) usp.set('onLoan', params.onLoan)
const qs = usp.toString()
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
},
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
post<Item>('/api/collection', body),
getItem: (id: number) => request<ItemDetail>(`/api/collection/${id}`),
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
setRip: (id: number, ripped: boolean | null) =>
request<Item>(`/api/collection/${id}/rip`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ripped }),
}),
setMatch: (id: number, albumId: number | null) =>
post<Item>(`/api/collection/${id}/match`, { albumId }),
syncStatus: () => request<SyncState>('/api/library/sync'),
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
getStats: () => request<Stats>('/api/stats'),
exportUrl: () => '/api/export',
lendItem: (id: number, borrower: string) => post<Loan>(`/api/collection/${id}/loan`, { borrower }),
getLoans: () => request<LoansResponse>('/api/loans'),
returnLoan: (id: number) => post<{ ok: boolean }>(`/api/loans/${id}/return`),
triggerBackup: () => post<{ file: string }>('/api/backup'),
getBackups: () => request<{ backups: BackupFile[] }>('/api/backups'),
}

70
web/src/auth.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { api } from './api'
import type { User } from './types'
export type AuthStatus = 'loading' | 'setup' | 'unauthenticated' | 'authenticated'
interface AuthContextValue {
status: AuthStatus
user: User | null
setupNeeded: boolean
refresh: () => Promise<void>
onSetupComplete: (user: User) => void
onLogin: (user: User) => void
onLogout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<User | null>(null)
const [setupNeeded, setSetupNeeded] = useState(false)
const refresh = useCallback(async () => {
try {
const { needed } = await api.setupStatus()
if (needed) {
setSetupNeeded(true)
setStatus('setup')
return
}
setSetupNeeded(false)
const { user: me } = await api.me()
setUser(me)
setStatus('authenticated')
} catch {
setStatus('unauthenticated')
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const onSetupComplete = useCallback((u: User) => {
setSetupNeeded(false)
setUser(u)
setStatus('authenticated')
}, [])
const onLogin = useCallback((u: User) => {
setUser(u)
setStatus('authenticated')
}, [])
const onLogout = useCallback(() => {
setUser(null)
setStatus('unauthenticated')
}, [])
return (
<AuthContext.Provider value={{ status, user, setupNeeded, refresh, onSetupComplete, onLogin, onLogout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth outside AuthProvider')
return ctx
}

View File

@@ -0,0 +1,26 @@
import type { Candidate } from '../types.js'
import Cover from './Cover.js'
export default function CandidateCard({
candidate,
onSelect,
}: {
candidate: Candidate
onSelect: (candidate: Candidate) => void
}) {
const meta = [candidate.year, candidate.formats[0], candidate.labels[0]].filter(Boolean).join(' · ')
return (
<button
type="button"
onClick={() => onSelect(candidate)}
className="flex w-full items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3 text-left transition-colors hover:border-neutral-600"
>
<Cover src={candidate.thumbUrl} alt="" className="size-16 shrink-0" />
<div className="min-w-0">
<p className="truncate font-medium">{candidate.title}</p>
<p className="truncate text-sm text-neutral-400">{candidate.artist}</p>
{meta && <p className="truncate text-xs text-neutral-500">{meta}</p>}
</div>
</button>
)
}

View File

@@ -0,0 +1,25 @@
export default function Cover({
src,
alt,
className = 'size-16',
}: {
src: string | null
alt: string
className?: string
}) {
if (!src) {
return (
<div
className={`flex items-center justify-center rounded-lg bg-neutral-800 text-neutral-600 ${className}`}
aria-label={alt}
role="img"
>
<svg viewBox="0 0 24 24" className="size-1/2" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)
}
return <img src={src} alt={alt} loading="lazy" className={`rounded-lg bg-neutral-800 object-cover ${className}`} />
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import type { Item } from '../types.js'
import Cover from './Cover.js'
export default function CoverGrid({ items }: { items: Item[] }) {
return (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{items.map((item) => (
<Link
key={item.id}
to={`/item/${item.id}`}
aria-label={`${item.artist}${item.title}`}
className="group"
>
<div className="relative">
<Cover src={item.artworkUrl} alt="" className="aspect-square w-full" />
<span
aria-label={item.ripStatus === 'ripped' ? 'ripped' : 'not ripped'}
className={`absolute right-1.5 top-1.5 size-3 rounded-full ring-2 ring-neutral-950 ${
item.ripStatus === 'ripped' ? 'bg-emerald-400' : 'bg-amber-500'
}`}
/>
</div>
<p className="mt-1 truncate text-xs font-medium">{item.title}</p>
<p className="truncate text-xs text-neutral-500">{item.artist}</p>
</Link>
))}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { useRef } from 'react'
import { useBarcodeScanner } from '../hooks/useBarcodeScanner.js'
export default function Scanner({
enabled,
onDetect,
}: {
enabled: boolean
onDetect: (code: string) => void
}) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const { status } = useBarcodeScanner(videoRef, onDetect, enabled)
return (
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-2xl bg-black">
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
<div
aria-hidden
className="pointer-events-none absolute inset-8 rounded-2xl border-2 border-emerald-400/80"
/>
{status === 'starting' && (
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-neutral-300">Starting camera</p>
)}
{status === 'denied' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center">
<p className="font-medium">Camera permission denied</p>
<p className="text-sm text-neutral-400">
Allow camera access in your browser settings, then reload this page.
</p>
</div>
)}
{status === 'error' && (
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-red-400">
Camera could not start.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
export type ScannerStatus = 'idle' | 'starting' | 'ready' | 'denied' | 'error'
export const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8'] as const
interface DetectedCode {
rawValue: string
format: string
}
interface BarcodeDetectorLike {
detect(source: HTMLVideoElement): Promise<DetectedCode[]>
}
type BarcodeDetectorCtor = new (options: { formats: string[] }) => BarcodeDetectorLike
export function getBarcodeDetectorCtor(): BarcodeDetectorCtor | null {
return (window as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector ?? null
}
/** Pure dedup rule: suppress a repeat of the same code within cooldownMs. */
export function shouldEmit(
last: { code: string; at: number } | null,
code: string,
now: number,
cooldownMs: number
): boolean {
if (!last) return true
if (last.code !== code) return true
return now - last.at >= cooldownMs
}
export function useBarcodeScanner(
videoRef: React.RefObject<HTMLVideoElement | null>,
onDetect: (code: string) => void,
enabled: boolean
): { status: ScannerStatus; stop: () => void } {
const [status, setStatus] = useState<ScannerStatus>('idle')
const onDetectRef = useRef(onDetect)
onDetectRef.current = onDetect
useEffect(() => {
if (!enabled) {
setStatus('idle')
return
}
let stopped = false
let stream: MediaStream | null = null
let rafId = 0
let zxingStop: (() => void) | null = null
let last: { code: string; at: number } | null = null
const emit = (code: string) => {
const now = Date.now()
if (!shouldEmit(last, code, now, 1500)) return
last = { code, at: now }
onDetectRef.current(code)
}
async function start(): Promise<void> {
setStatus('starting')
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
} catch (err) {
setStatus(err instanceof DOMException && err.name === 'NotAllowedError' ? 'denied' : 'error')
return
}
const video = videoRef.current
if (!video || stopped) {
stream.getTracks().forEach((t) => t.stop())
return
}
video.srcObject = stream
await video.play().catch(() => {})
const Ctor = getBarcodeDetectorCtor()
if (Ctor) {
const detector = new Ctor({ formats: [...BARCODE_FORMATS] })
const tick = async (): Promise<void> => {
if (stopped) return
try {
const codes = await detector.detect(video)
const first = codes[0]
if (first) emit(first.rawValue)
} catch {
// undecodable frame — skip
}
rafId = requestAnimationFrame(() => void tick())
}
void tick()
} else {
const { BrowserMultiFormatReader, BarcodeFormat, DecodeHintType } = await import('@zxing/library')
if (stopped) return
const hints = new Map()
hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.EAN_8])
const reader = new BrowserMultiFormatReader(hints)
// 0.23.0's decodeFromStream returns Promise<void> and only resolves once
// its decode loop is stopped — start it without awaiting and break the
// loop on cleanup via stopContinuousDecode().
zxingStop = () => reader.stopContinuousDecode()
void reader
.decodeFromStream(stream, video, (result) => {
if (result) emit(result.getText())
})
.catch(() => {})
}
if (!stopped) setStatus('ready')
}
void start()
return () => {
stopped = true
cancelAnimationFrame(rafId)
zxingStop?.()
stream?.getTracks().forEach((t) => t.stop())
setStatus('idle')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, videoRef])
return { status, stop: () => undefined }
}

14
web/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
void navigator.serviceWorker.register('/sw.js')
}

206
web/src/pages/AddPage.tsx Normal file
View File

@@ -0,0 +1,206 @@
import { useCallback, useEffect, useReducer, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api, ApiError } from '../api.js'
import CandidateCard from '../components/CandidateCard.js'
import ConfirmView from '../scan/ConfirmView.js'
import Cover from '../components/Cover.js'
import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js'
function toErrorKind(err: unknown): ScanErrorKind {
if (err instanceof ApiError) {
if (err.code === 'not_found') return 'not_found'
if (err.code === 'no_discogs_token') return 'no_discogs_token'
if (err.code === 'discogs_auth') return 'discogs_auth'
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
}
return 'server'
}
const FORMATS = ['Vinyl', 'CD', 'Cassette'] as const
export default function AddPage() {
const [params] = useSearchParams()
const [q, setQ] = useState(params.get('q') ?? '')
const [format, setFormat] = useState<(typeof FORMATS)[number] | ''>('')
const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
const search = useCallback(
(query: string) => {
if (!query.trim()) return
dispatch({ type: 'DETECT', code: query.trim() })
void (async () => {
try {
const { candidates } = await api.lookupSearch(query.trim(), format || undefined)
if (candidates.length === 0) {
dispatch({ type: 'NOT_FOUND', code: query.trim() })
} else {
dispatch({ type: 'CANDIDATES', code: query.trim(), candidates })
}
} catch (err) {
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: query.trim(), source: 'lookup' })
}
})()
},
[format]
)
// Load the release preview once a candidate is selected.
useEffect(() => {
if (state.phase !== 'confirm' || state.preview) return
const candidateId = state.candidate.id
void (async () => {
try {
const preview = await api.getReleasePreview(candidateId)
dispatch({ type: 'PREVIEW', preview, candidateId })
} catch (err) {
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId })
}
})()
}, [state])
const add = useCallback(() => {
if (state.phase !== 'confirm' || !state.preview || state.adding) return
const { candidate, matchAlbumId } = state
dispatch({ type: 'ADD_START' })
void (async () => {
try {
const item = await api.addToCollection({
releaseId: candidate.id,
...(matchAlbumId !== null ? { matchAlbumId } : {}),
})
dispatch({ type: 'ADDED', item })
} catch (err) {
const message =
err instanceof ApiError
? err.code === 'duplicate'
? 'Already in your collection.'
: err.detail ?? err.code
: 'Something went wrong'
dispatch({ type: 'ADD_ERROR', message })
}
})()
}, [state])
return (
<div className="space-y-4">
{state.phase === 'scan' && (
<form
onSubmit={(e) => {
e.preventDefault()
search(q)
}}
className="space-y-3"
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Artist and title"
className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<label className="sr-only" htmlFor="format">
Format
</label>
<select
id="format"
value={format}
onChange={(e) => setFormat(e.target.value as (typeof FORMATS)[number] | '')}
className="rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
>
<option value="">Any format</option>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<button type="submit" className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950">
Search
</button>
</div>
<Link to="/scan" className="block text-center text-sm text-neutral-400">
or scan a barcode
</Link>
</form>
)}
{state.phase === 'looking' && <p className="py-8 text-center text-neutral-400">Searching</p>}
{state.phase === 'candidates' && (
<div className="space-y-3">
<p className="text-sm text-neutral-400">Which release is it?</p>
{state.candidates.map((c) => (
<CandidateCard key={c.id} candidate={c} onSelect={(cand) => dispatch({ type: 'SELECT', candidate: cand })} />
))}
</div>
)}
{state.phase === 'confirm' && (
<div className="space-y-4">
{!state.preview && <p className="py-8 text-center text-neutral-400">Checking release</p>}
{state.preview && (
<ConfirmView
code={null}
preview={state.preview}
matchAlbumId={state.matchAlbumId}
onSetMatch={(albumId) => dispatch({ type: 'SET_MATCH', albumId })}
onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
onAdd={add}
adding={state.adding}
addError={state.addError}
/>
)}
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Back to search
</button>
</div>
)}
{state.phase === 'added' && (
<div className="space-y-4 text-center">
<Cover src={state.item.artworkUrl} alt="" className="mx-auto size-32" />
<p className="text-lg font-medium">Added to collection </p>
<Link className="block text-sm text-emerald-400" to={`/item/${state.item.id}`}>
View item
</Link>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Add another
</button>
</div>
)}
{state.phase === 'error' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">
{state.kind === 'not_found'
? 'Nothing found'
: state.kind === 'discogs_auth'
? 'Discogs rejected your token'
: 'Search failed'}
</p>
<p className="text-sm text-neutral-400">
{state.kind === 'not_found' && 'Try different spelling, or add the year.'}
{state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'}
{state.kind === 'discogs_auth' && 'Check your Discogs token in Settings.'}
{state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'}
</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Back
</button>
</div>
)}
</div>
)
}

334
web/src/pages/ItemPage.tsx Normal file
View File

@@ -0,0 +1,334 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../api.js'
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.js'
function timeAgo(iso: string): string {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (secs < 60) return 'just now'
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago`
}
export default function ItemPage() {
const { id } = useParams()
const navigate = useNavigate()
const [item, setItem] = useState<ItemDetail | null>(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
const [albumQuery, setAlbumQuery] = useState('')
const [albums, setAlbums] = useState<DigitalAlbum[] | null>(null)
const [pickedAlbum, setPickedAlbum] = useState<number | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState<string | null>(null)
const refetch = useCallback(() => {
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
}, [id])
useEffect(() => {
setMutationError(null)
refetch()
}, [refetch])
/** Rip/match mutations return a plain Item — keep the detail-only fields. */
function applyUpdated(updated: Item) {
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
}
function searchAlbums() {
void api
.searchAlbums(albumQuery)
.then((res) => {
setAlbums(res.albums)
setPickedAlbum(null)
})
.catch(() => setAlbums([]))
}
function applyMatch(albumId: number | null) {
if (!item) return
void api
.setMatch(item.id, albumId)
.then(() => refetch())
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
function remove() {
if (!item) return
void api.deleteItem(item.id).then(() => navigate('/library')).catch(() =>
setMutationError("That didn't work — check your connection and try again.")
)
}
if (error) return <p className="py-8 text-center text-sm text-red-400">Item not found.</p>
if (!item) return <p className="py-8 text-center text-sm text-neutral-400">Loading</p>
return (
<div className="space-y-4">
<div className="flex gap-4">
<Cover src={item.artworkUrl} alt="" className="size-32 shrink-0" />
<div className="min-w-0">
<h2 className="text-lg font-semibold leading-tight">{item.title}</h2>
<p className="text-neutral-400">{item.artist}</p>
<p className="text-xs text-neutral-500">
{[item.year, item.formats.join(', '), item.labels.join(', '), item.catno, item.country]
.filter(Boolean)
.join(' · ')}
</p>
{item.genres.length > 0 && <p className="mt-1 text-xs text-neutral-500">{item.genres.join(', ')}</p>}
<a
href={`https://www.discogs.com/release/${item.discogsReleaseId}`}
target="_blank"
rel="noreferrer"
className="mt-1 inline-block text-xs text-emerald-400"
>
View on Discogs
</a>
</div>
</div>
{item.ripStatus === 'ripped' ? (
<p className="rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
In your digital collection
{item.ripOverride !== null && ' (manually set)'}
</p>
) : (
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
Not ripped yet
{item.ripOverride !== null && ' (manually set)'}
</p>
)}
{item.matchedAlbum?.webUrl && (
<a
href={item.matchedAlbum.webUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
>
Listen in Navidrome
</a>
)}
{item.matchedAlbum?.lastPlayedAt && (
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
)}
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
<div className="flex flex-wrap gap-2">
{item.ripStatus === 'ripped' ? (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, false)
.then((updated) => {
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark not ripped
</button>
) : (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, true)
.then((updated) => {
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark ripped
</button>
)}
{item.ripOverride !== null && (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, null)
.then((updated) => {
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Reset to auto
</button>
)}
</div>
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<button
type="button"
onClick={() => setMatching((m) => !m)}
className="text-sm font-medium text-neutral-200"
aria-expanded={matching}
>
Re-match
</button>
{matching && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<input
value={albumQuery}
onChange={(e) => setAlbumQuery(e.target.value)}
placeholder="Search your digital library"
className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
/>
<button
type="button"
onClick={searchAlbums}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Search
</button>
</div>
{albums && albums.length === 0 && <p className="text-sm text-neutral-500">No matches in your library.</p>}
{albums && albums.length > 0 && (
<div className="space-y-1.5">
{albums.map((a) => (
<label key={a.id} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="album"
checked={pickedAlbum === a.id}
onChange={() => setPickedAlbum(a.id)}
/>
{a.artist} {a.title}
</label>
))}
</div>
)}
<div className="flex gap-2">
<button
type="button"
disabled={pickedAlbum === null}
onClick={() => applyMatch(pickedAlbum)}
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950 disabled:opacity-50"
>
Link
</button>
<button
type="button"
onClick={() => applyMatch(null)}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Unlink
</button>
</div>
</div>
)}
</section>
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
{item.loan ? (
<div className="flex items-center justify-between">
<p className="text-sm text-neutral-300">
Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
</p>
<button
type="button"
onClick={() =>
void api
.returnLoan(item.loan!.id)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark returned
</button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
void api
.lendItem(item.id, borrower)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}}
className="flex gap-2"
>
<input
name="borrower"
aria-label="Borrower"
placeholder="Lend to…"
required
className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
/>
<button type="submit" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300">
Lend
</button>
</form>
)}
</section>
{item.tracklist.length > 0 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
<ol className="mt-2 space-y-1 text-sm text-neutral-400">
{item.tracklist.map((t, i) => (
<li key={i} className="flex gap-2">
<span className="w-8 shrink-0 text-neutral-600">{t.position}</span>
<span>{t.title}</span>
</li>
))}
</ol>
</details>
)}
{item.barcodes.length > 0 && (
<p className="text-xs text-neutral-500">Barcodes: {item.barcodes.join(', ')}</p>
)}
<div className="flex justify-between border-t border-neutral-800 pt-4">
<Link to="/library" className="text-sm text-neutral-400">
Back
</Link>
{confirmRemove ? (
<button type="button" onClick={remove} className="text-sm font-medium text-red-400">
Confirm remove
</button>
) : (
<button type="button" onClick={() => setConfirmRemove(true)} className="text-sm text-red-400">
Remove
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import type { CollectionResponse } from '../types.js'
import CoverGrid from '../components/CoverGrid.js'
const FORMATS = ['All', 'Vinyl', 'CD', 'Cassette'] as const
const RIP = ['All', 'Ripped', 'Not ripped'] as const
export default function LibraryPage() {
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
const [onLoan, setOnLoan] = useState(false)
const [q, setQ] = useState('')
const [data, setData] = useState<CollectionResponse | null>(null)
const [error, setError] = useState(false)
const seq = useRef(0)
useEffect(() => {
const mine = ++seq.current
const params = {
...(format !== 'All' ? { format } : {}),
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
...(onLoan ? { onLoan: 'true' } : {}),
...(q.trim() ? { q: q.trim() } : {}),
}
void api
.listCollection(params)
.then((res) => {
if (seq.current === mine) {
setData(res)
setError(false)
}
})
.catch(() => {
if (seq.current === mine) setError(true)
})
}, [format, ripped, onLoan, q])
const artists = useMemo(() => {
const set = new Set<string>()
for (const item of data?.items ?? []) set.add(item.artist)
return [...set].sort((a, b) => a.localeCompare(b))
}, [data])
return (
<div className="space-y-4">
<div className="flex gap-3 text-sm">
<Link to="/stats" className="text-emerald-400">
Stats
</Link>
<Link to="/queue" className="text-emerald-400">
Queue
</Link>
</div>
<input
type="search"
placeholder="Search title or artist"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
/>
<div className="flex flex-wrap gap-2">
{FORMATS.map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
format === f ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
{f}
</button>
))}
{RIP.map((r) => (
<button
key={r}
type="button"
onClick={() => setRipped(r)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
ripped === r ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
{r}
</button>
))}
<button
type="button"
onClick={() => setOnLoan((v) => !v)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
onLoan ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
On loan
</button>
</div>
{error && <p className="text-sm text-red-400">Could not load your collection.</p>}
{data && (
<>
<p className="text-xs text-neutral-500">
{data.counts.total} in collection · {data.counts.ripped} ripped · {data.counts.notRipped} not ripped
</p>
{data.items.length === 0 ? (
<div className="py-12 text-center">
<p className="text-neutral-400">Nothing here yet.</p>
<Link to="/add" className="mt-2 inline-block text-sm text-emerald-400">
Add your first record
</Link>
</div>
) : (
<CoverGrid items={data.items} />
)}
{!q && artists.length > 1 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<summary className="cursor-pointer text-sm text-neutral-300">Artists</summary>
<div className="mt-2 flex flex-wrap gap-1.5">
{artists.map((artist) => (
<button
key={artist}
type="button"
onClick={() => setQ(artist)}
className="rounded-full border border-neutral-700 px-2.5 py-0.5 text-xs text-neutral-300"
>
{artist}
</button>
))}
</div>
</details>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../api'
import { useAuth } from '../auth'
export default function LoginPage() {
const { onLogin } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const { user } = await api.login(username, password)
onLogin(user)
navigate('/library', { replace: true })
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100 flex items-center justify-center p-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">Sign in</h1>
<div>
<label htmlFor="username" className="mb-1 block text-sm">
Username
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="username"
required
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-emerald-500 py-2 font-medium text-neutral-950 disabled:opacity-50"
>
Sign in
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import type { Item } from '../types.js'
import Cover from '../components/Cover.js'
export default function QueuePage() {
const [items, setItems] = useState<Item[] | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
void api
.listCollection({ ripped: 'not_ripped' })
.then((res) => setItems([...res.items].reverse()))
.catch(() => setError(true))
}, [])
function markRipped(id: number) {
void api
.setRip(id, true)
.then(() => setItems((list) => (list ?? []).filter((i) => i.id !== id)))
.catch(() => setError(true))
}
return (
<div className="space-y-3">
<p className="text-sm text-neutral-400">Rip queue oldest additions first.</p>
{error && <p className="text-sm text-red-400">Could not load the queue.</p>}
{items && items.length === 0 && (
<div className="py-12 text-center">
<p className="text-neutral-400">Nothing waiting to be ripped.</p>
<Link to="/scan" className="mt-2 inline-block text-sm text-emerald-400">
Scan something
</Link>
</div>
)}
{items &&
items.map((item) => (
<div key={item.id} className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<Cover src={item.artworkUrl} alt="" className="size-12 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
<p className="truncate text-xs text-neutral-400">
{item.artist} · added {new Date(item.dateAdded + 'Z').toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => markRipped(item.id)}
className="shrink-0 rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950"
>
Mark ripped
</button>
<Link to={`/item/${item.id}`} className="shrink-0 text-xs text-neutral-400">
details
</Link>
</div>
))}
</div>
)
}

213
web/src/pages/ScanPage.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { useCallback, useEffect, useReducer } from 'react'
import { Link } from 'react-router-dom'
import { api, ApiError } from '../api.js'
import Scanner from '../components/Scanner.js'
import CandidateCard from '../components/CandidateCard.js'
import ConfirmView from '../scan/ConfirmView.js'
import Cover from '../components/Cover.js'
import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js'
function toErrorKind(err: unknown): ScanErrorKind {
if (err instanceof ApiError) {
if (err.code === 'not_found') return 'not_found'
if (err.code === 'no_discogs_token') return 'no_discogs_token'
if (err.code === 'discogs_auth') return 'discogs_auth'
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
}
return 'server'
}
export default function ScanPage() {
const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
const onDetect = useCallback((code: string) => {
dispatch({ type: 'DETECT', code })
void (async () => {
try {
const { candidates } = await api.lookupBarcode(code)
if (candidates.length === 0) {
dispatch({ type: 'NOT_FOUND', code })
} else {
dispatch({ type: 'CANDIDATES', code, candidates })
}
} catch (err) {
dispatch({ type: 'ERROR', kind: toErrorKind(err), code, source: 'lookup' })
}
})()
}, [])
// Load the release preview once a candidate is selected.
useEffect(() => {
if (state.phase !== 'confirm' || state.preview) return
const candidateId = state.candidate.id
void (async () => {
try {
const preview = await api.getReleasePreview(candidateId)
dispatch({ type: 'PREVIEW', preview, candidateId })
} catch (err) {
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId })
}
})()
}, [state])
const add = useCallback(() => {
if (state.phase !== 'confirm' || !state.preview || state.adding) return
const { candidate, code, matchAlbumId } = state
dispatch({ type: 'ADD_START' })
void (async () => {
try {
const item = await api.addToCollection({
releaseId: candidate.id,
...(code ? { barcode: code } : {}),
...(matchAlbumId !== null ? { matchAlbumId } : {}),
})
dispatch({ type: 'ADDED', item })
} catch (err) {
const message =
err instanceof ApiError
? err.code === 'duplicate'
? 'Already in your collection.'
: err.detail ?? err.code
: 'Something went wrong'
dispatch({ type: 'ADD_ERROR', message })
}
})()
}, [state])
return (
<div className="space-y-4">
{state.phase === 'scan' && <Scanner enabled onDetect={onDetect} />}
{state.phase === 'looking' && (
<p className="py-8 text-center text-neutral-400">Looking up {state.code}</p>
)}
{state.phase === 'candidates' && (
<div className="space-y-3">
<p className="text-sm text-neutral-400">Which release is it?</p>
{state.candidates.map((c) => (
<CandidateCard key={c.id} candidate={c} onSelect={(cand) => dispatch({ type: 'SELECT', candidate: cand })} />
))}
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Scan another
</button>
</div>
)}
{state.phase === 'confirm' && (
<div className="space-y-4">
{!state.preview && <p className="py-8 text-center text-neutral-400">Checking release</p>}
{state.preview && (
<ConfirmView
code={state.code}
preview={state.preview}
matchAlbumId={state.matchAlbumId}
onSetMatch={(albumId) => dispatch({ type: 'SET_MATCH', albumId })}
onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
onAdd={add}
adding={state.adding}
addError={state.addError}
/>
)}
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Cancel
</button>
</div>
)}
{state.phase === 'added' && (
<div className="space-y-4 text-center">
<Cover src={state.item.artworkUrl} alt="" className="mx-auto size-32" />
<p className="text-lg font-medium">Added to collection </p>
<p className="text-sm text-neutral-400">
{state.item.artist} {state.item.title}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950"
>
Scan another
</button>
<Link
to={`/item/${state.item.id}`}
className="flex-1 rounded-xl border border-neutral-700 py-2 text-center text-sm text-neutral-300"
>
View item
</Link>
</div>
</div>
)}
{state.phase === 'error' && state.kind === 'not_found' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Nothing found</p>
<p className="text-sm text-neutral-400">
Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode.
</p>
<Link
to={`/add?q=${encodeURIComponent(state.code ?? '')}`}
className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950"
>
Search manually
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'no_discogs_token' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Add your Discogs token first</p>
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
Settings
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'rate_limited' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Slow down</p>
<p className="text-sm text-neutral-400">Discogs is rate limiting us. Try again in a moment.</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Try again
</button>
</div>
)}
{state.phase === 'error' && state.kind === 'discogs_auth' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Discogs rejected your token</p>
<p className="text-sm text-neutral-400">Check your Discogs token in Settings.</p>
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
Settings
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'server' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Lookup failed</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Try again
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,326 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import type { BackupFile, SettingsView, SyncState, User } from '../types.js'
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-3 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
{children}
</section>
)
}
const inputCls = 'w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm'
export default function SettingsPage() {
const { user, onLogout } = useAuth()
const navigate = useNavigate()
const [view, setView] = useState<SettingsView | null>(null)
const [discogsToken, setDiscogsToken] = useState('')
const [subsonicUrl, setSubsonicUrl] = useState('')
const [subsonicUsername, setSubsonicUsername] = useState('')
const [subsonicPassword, setSubsonicPassword] = useState('')
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
const [sync, setSync] = useState<SyncState | null>(null)
const [users, setUsers] = useState<User[] | null>(null)
const [backups, setBackups] = useState<BackupFile[] | null>(null)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const stopPollRef = useRef<(() => void) | null>(null)
const refreshSettings = useCallback(() => {
void api
.getSettings()
.then((v) => {
setView(v)
setSubsonicUrl(v.subsonicUrl ?? '')
setSubsonicUsername(v.subsonicUsername ?? '')
})
.catch(() => {})
}, [])
useEffect(() => {
refreshSettings()
}, [refreshSettings])
const pollSync = useCallback(() => {
let alive = true
const tick = (): void => {
void api
.syncStatus()
.then((s) => {
if (!alive) return s
setSync(s)
return s
})
.then((s) => {
if (alive && s?.status === 'running') setTimeout(tick, 2000)
})
.catch(() => {})
}
tick()
return () => {
alive = false
}
}, [])
useEffect(() => {
stopPollRef.current = pollSync()
return () => stopPollRef.current?.()
}, [pollSync])
useEffect(() => {
if (user?.isAdmin) {
void api
.listUsers()
.then((res) => setUsers(res.users))
.catch(() => {})
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
}
}, [user])
function flash(kind: 'ok' | 'error', text: string) {
setMessage({ kind, text })
setTimeout(() => setMessage(null), 4000)
}
function saveDiscogs(e: FormEvent) {
e.preventDefault()
void api
.putSettings({ discogsToken: discogsToken })
.then((v) => {
setView(v)
setDiscogsToken('')
flash('ok', 'Token saved')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
}
function saveSubsonic(e: FormEvent) {
e.preventDefault()
const payload: Parameters<typeof api.putSettings>[0] = { subsonicUrl, subsonicUsername }
if (subsonicPassword !== '') payload.subsonicPassword = subsonicPassword
void api
.putSettings(payload)
.then((v) => {
setView(v)
setSubsonicPassword('')
flash('ok', 'Music server saved — syncing library')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
}
function addUser(e: FormEvent) {
e.preventDefault()
void api
.createUser(newUsername, newPassword)
.then((created) => {
setUsers((u) => [...(u ?? []), created])
setNewUsername('')
setNewPassword('')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Could not add user'))
}
function removeUser(id: number, username: string) {
void api.deleteUser(id).then(() => setUsers((u) => (u ?? []).filter((x) => x.id !== id || x.username !== username)))
}
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Settings</h1>
{message && (
<p className={`rounded-xl px-4 py-3 text-sm ${message.kind === 'ok' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{message.text}
</p>
)}
<Section title="Account">
<p className="text-sm">
{user?.username} {user?.isAdmin && <span className="text-neutral-500">(admin)</span>}
</p>
<button
type="button"
onClick={() =>
void api.logout().then(() => {
onLogout()
navigate('/login')
})
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Log out
</button>
</Section>
<Section title="Discogs">
<p className="text-xs text-neutral-500">
Personal access token from discogs.com Settings Developers.{' '}
{view?.hasDiscogsToken && `Current: ${view.discogsTokenMasked}`}
</p>
<form onSubmit={saveDiscogs} className="space-y-2">
<label className="block text-sm">
Discogs token
<input
value={discogsToken}
onChange={(e) => setDiscogsToken(e.target.value)}
className={inputCls}
autoComplete="off"
placeholder={view?.hasDiscogsToken ? 'Paste token (saving empty removes it)' : 'Paste token'}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Save Discogs
</button>
</form>
</Section>
<Section title="Music server (Subsonic)">
<form onSubmit={saveSubsonic} className="space-y-2">
<label className="block text-sm">
Subsonic URL
<input value={subsonicUrl} onChange={(e) => setSubsonicUrl(e.target.value)} className={inputCls} placeholder="http://navidrome.local" />
</label>
<label className="block text-sm">
Subsonic username
<input value={subsonicUsername} onChange={(e) => setSubsonicUsername(e.target.value)} className={inputCls} autoComplete="off" />
</label>
<label className="block text-sm">
Subsonic password
<input
type="password"
value={subsonicPassword}
onChange={(e) => setSubsonicPassword(e.target.value)}
className={inputCls}
autoComplete="new-password"
placeholder={view?.hasSubsonicPassword ? 'Saved — leave blank to keep' : ''}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Save Subsonic
</button>
</form>
</Section>
<Section title="Library sync">
{sync && (
<p className="text-sm text-neutral-300">
{sync.status === 'running' && 'Syncing…'}
{sync.status === 'done' && `${sync.albums} albums synced`}
{sync.status === 'error' && <span className="text-red-400">Sync failed: {sync.error}</span>}
{sync.status === 'idle' && 'Not synced yet'}
{sync.lastSyncedAt && (
<span className="text-neutral-500"> · last {new Date(sync.lastSyncedAt).toLocaleString()}</span>
)}
</p>
)}
<button
type="button"
onClick={() =>
void api
.startSync()
.then((s) => {
setSync(s)
stopPollRef.current?.()
stopPollRef.current = pollSync()
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Sync failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Sync now
</button>
</Section>
<Section title="Data">
<div className="flex flex-wrap gap-2">
<a
href={api.exportUrl()}
download
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Export JSON
</a>
{user?.isAdmin && (
<button
type="button"
onClick={() =>
void api
.triggerBackup()
.then(() => api.getBackups())
.then((res) => setBackups(res.backups))
.then(() => flash('ok', 'Backup created'))
.catch(() => flash('error', 'Backup failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Back up now
</button>
)}
</div>
{user?.isAdmin && backups && backups.length > 0 && (
<ul className="space-y-1 text-xs text-neutral-400">
{backups.map((b) => (
<li key={b.file} className="flex justify-between gap-2">
<span className="truncate">{b.file}</span>
<span className="shrink-0">
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</Section>
{user?.isAdmin && (
<Section title="Users">
<ul className="space-y-1.5 text-sm">
{(users ?? []).map((u) => (
<li key={u.id} className="flex items-center justify-between">
<span>
{u.username} {u.isAdmin && <span className="text-neutral-500">(admin)</span>}
</span>
{!u.isAdmin && (
<button
type="button"
onClick={() => removeUser(u.id, u.username)}
className="text-xs text-red-400"
aria-label={`remove ${u.username}`}
>
remove
</button>
)}
</li>
))}
</ul>
<form onSubmit={addUser} className="space-y-2 border-t border-neutral-800 pt-3">
<label className="block text-sm">
New username
<input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className={inputCls} autoComplete="off" />
</label>
<label className="block text-sm">
New password
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputCls}
autoComplete="new-password"
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Add user
</button>
</form>
</Section>
)}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../api'
import { useAuth } from '../auth'
export default function SetupPage() {
const { onSetupComplete } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const { user } = await api.setup(username, password)
onSetupComplete(user)
navigate('/library', { replace: true })
} catch (err) {
setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100 flex items-center justify-center p-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">Welcome to record-shop</h1>
<p className="text-sm text-neutral-400">Create the admin account to get started.</p>
<div>
<label htmlFor="username" className="mb-1 block text-sm">
Username
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="username"
required
minLength={3}
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="new-password"
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-emerald-500 py-2 font-medium text-neutral-950 disabled:opacity-50"
>
Create admin account
</button>
</form>
</div>
)
}

111
web/src/pages/StatsPage.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import type { Stats } from '../types.js'
function Bar({ name, count, max }: { name: string; count: number; max: number }) {
return (
<div className="flex items-center gap-2 text-sm">
<span className="w-28 shrink-0 truncate text-neutral-300">{name}</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-neutral-800">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${max === 0 ? 0 : (count / max) * 100}%` }} />
</div>
<span className="w-8 shrink-0 text-right text-neutral-500">{count}</span>
</div>
)
}
function Card({ value, label }: { value: string | number; label: string }) {
return (
<div className="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-center">
<p className="text-2xl font-semibold">{value}</p>
<p className="text-xs text-neutral-400">{label}</p>
</div>
)
}
export default function StatsPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
void api
.getStats()
.then(setStats)
.catch(() => setError(true))
}, [])
if (error) return <p className="py-8 text-center text-sm text-red-400">Could not load stats.</p>
if (!stats) return <p className="py-8 text-center text-sm text-neutral-400">Loading</p>
const maxOf = (rows: { count: number }[]) => Math.max(1, ...rows.map((r) => r.count))
const maxMonth = Math.max(1, ...stats.addedByMonth.map((m) => m.count))
const ratioPct = Math.round(stats.ripRatio * 100)
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card value={stats.totals.items} label="in collection" />
<Card value={stats.totals.ripped} label="ripped" />
<Card value={stats.totals.notRipped} label="not ripped" />
<Card value={stats.totals.onLoan} label="on loan" />
</div>
<div className="flex items-center gap-4 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<div
className="size-20 shrink-0 rounded-full"
style={{
background: `conic-gradient(#34d399 ${ratioPct}%, #262626 ${ratioPct}% 100%)`,
}}
role="img"
aria-label={`rip ratio ${ratioPct}%`}
/>
<p className="text-sm text-neutral-300">{ratioPct}% ripped</p>
</div>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Formats</h2>
{stats.formats.length === 0 && <p className="text-sm text-neutral-500">No data yet.</p>}
{stats.formats.map((f) => (
<Bar key={f.name} name={f.name} count={f.count} max={maxOf(stats.formats)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Genres</h2>
{stats.topGenres.map((g) => (
<Bar key={g.name} name={g.name} count={g.count} max={maxOf(stats.topGenres)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Top artists</h2>
{stats.topArtists.map((a) => (
<Bar key={a.name} name={a.name} count={a.count} max={maxOf(stats.topArtists)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Decades</h2>
{stats.decades.map((d) => (
<Bar key={d.name} name={d.name} count={d.count} max={maxOf(stats.decades)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Added per month</h2>
<div className="flex h-24 items-end gap-1">
{stats.addedByMonth.map((m) => (
<div key={m.month} className="flex flex-1 flex-col items-center gap-1" title={`${m.month}: ${m.count}`}>
<div
data-bar
className="w-full rounded-t bg-emerald-500"
style={{ height: `${(m.count / maxMonth) * 100}%`, minHeight: m.count > 0 ? '4px' : '1px', background: m.count > 0 ? '#34d399' : '#262626' }}
/>
<span className="text-[10px] text-neutral-500">{m.month.slice(5)}</span>
</div>
))}
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import type { ReleasePreview } from '../types.js'
import Cover from '../components/Cover.js'
export default function ConfirmView({
code,
preview,
matchAlbumId,
onSetMatch,
onClearMatch,
onAdd,
adding,
addError,
}: {
code: string | null
preview: ReleasePreview
matchAlbumId: number | null
onSetMatch: (albumId: number) => void
onClearMatch: () => void
onAdd: () => void
adding: boolean
addError: string | null
}) {
const { release, duplicate, ripMatch, matchCandidates } = preview
return (
<div className="space-y-4">
<div className="flex gap-4">
<Cover src={release.coverUrl ?? release.thumbUrl} alt="" className="size-28 shrink-0" />
<div className="min-w-0">
<h2 className="text-lg font-semibold leading-tight">{release.title}</h2>
<p className="text-neutral-400">{release.artist}</p>
<p className="text-xs text-neutral-500">
{[release.year, release.formats.join(', '), release.labels[0], release.catno]
.filter(Boolean)
.join(' · ')}
</p>
{code && <p className="mt-1 text-xs text-neutral-500">Barcode {code}</p>}
</div>
</div>
{ripMatch === 'ripped' && (
<p className="rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
In your digital collection
</p>
)}
{ripMatch === 'not_ripped' && (
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">Not ripped yet</p>
)}
{ripMatch === 'ambiguous' && (
<fieldset className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
<legend className="px-1">Possible matches in your library which one is it?</legend>
<div className="mt-1 space-y-2">
{matchCandidates.map((m) => (
<label key={m.id} className="flex items-center gap-2 text-neutral-200">
<input
type="radio"
name="match"
checked={matchAlbumId === m.id}
onChange={() => onSetMatch(m.id)}
/>
{m.artist} {m.title}
</label>
))}
<label className="flex items-center gap-2 text-neutral-200">
<input type="radio" name="match" checked={matchAlbumId === null} onChange={onClearMatch} />
None of these just add it
</label>
</div>
</fieldset>
)}
{duplicate && (
<p className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-400">
Heads up: this release is already in your collection.
</p>
)}
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
<ol className="mt-2 space-y-1 text-sm text-neutral-400">
{release.tracklist.map((t, i) => (
<li key={i} className="flex gap-2">
<span className="w-8 shrink-0 text-neutral-600">{t.position}</span>
<span>{t.title}</span>
</li>
))}
</ol>
</details>
{addError && <p className="text-sm text-red-400">{addError}</p>}
<button
type="button"
onClick={onAdd}
disabled={adding}
className="w-full rounded-xl bg-emerald-500 py-3 font-medium text-neutral-950 disabled:opacity-50"
>
{adding ? 'Adding…' : 'Add to collection'}
</button>
</div>
)
}

81
web/src/scan/reducer.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Candidate, Item, ReleasePreview } from '../types.js'
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'discogs_auth' | 'rate_limited' | 'server'
export type ScanState =
| { phase: 'scan' }
| { phase: 'looking'; code: string }
| { phase: 'candidates'; code: string; candidates: Candidate[] }
| {
phase: 'confirm'
code: string | null
candidate: Candidate
preview: ReleasePreview | null
matchAlbumId: number | null
adding: boolean
addError: string | null
}
| { phase: 'added'; item: Item }
| { phase: 'error'; kind: ScanErrorKind; code: string | null }
export const INITIAL_SCAN_STATE: ScanState = { phase: 'scan' }
export type ScanAction =
| { type: 'DETECT'; code: string }
| { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
| { type: 'NOT_FOUND'; code: string }
| { type: 'ERROR'; kind: ScanErrorKind; code: string | null; source: 'lookup' | 'preview'; candidateId?: number }
| { type: 'SELECT'; candidate: Candidate }
| { type: 'PREVIEW'; preview: ReleasePreview; candidateId: number }
| { type: 'SET_MATCH'; albumId: number }
| { type: 'CLEAR_MATCH' }
| { type: 'ADD_START' }
| { type: 'ADDED'; item: Item }
| { type: 'ADD_ERROR'; message: string }
| { type: 'RESET' }
export function scanReducer(state: ScanState, action: ScanAction): ScanState {
switch (action.type) {
case 'DETECT':
return state.phase === 'scan' ? { phase: 'looking', code: action.code } : state
case 'CANDIDATES':
return state.phase === 'looking' ? { phase: 'candidates', code: action.code, candidates: action.candidates } : state
case 'NOT_FOUND':
return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
case 'ERROR':
if (action.source === 'lookup') {
return state.phase === 'looking' ? { phase: 'error', kind: action.kind, code: action.code } : state
}
return state.phase === 'confirm' && state.candidate.id === action.candidateId
? { phase: 'error', kind: action.kind, code: action.code }
: state
case 'SELECT':
return state.phase === 'candidates'
? {
phase: 'confirm',
code: state.code,
candidate: action.candidate,
preview: null,
matchAlbumId: null,
adding: false,
addError: null,
}
: state
case 'PREVIEW':
return state.phase === 'confirm' && state.candidate.id === action.candidateId
? { ...state, preview: action.preview }
: state
case 'SET_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
case 'CLEAR_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: null } : state
case 'ADD_START':
return state.phase === 'confirm' && state.preview ? { ...state, adding: true, addError: null } : state
case 'ADDED':
return state.phase === 'confirm' ? { phase: 'added', item: action.item } : state
case 'ADD_ERROR':
return state.phase === 'confirm' ? { ...state, adding: false, addError: action.message } : state
case 'RESET':
return INITIAL_SCAN_STATE
}
}

79
web/src/shell.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { NavLink, Outlet } from 'react-router-dom'
import type { ReactNode } from 'react'
const TABS: { to: string; label: string; icon: ReactNode }[] = [
{
to: '/library',
label: 'Library',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
),
},
{
to: '/scan',
label: 'Scan',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2M7 12h10" />
</svg>
),
},
{
to: '/add',
label: 'Add',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v8M8 12h8" />
</svg>
),
},
{
to: '/settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
),
},
]
export default function Shell({ title }: { title?: string }) {
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100">
<header className="sticky top-0 z-10 border-b border-neutral-800 bg-neutral-950/90 backdrop-blur">
<h1 className="mx-auto max-w-3xl px-4 py-3 text-lg font-semibold">{title ?? 'record-shop'}</h1>
</header>
<main className="mx-auto max-w-3xl px-4 pb-24 pt-4">
<Outlet />
</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 border-t border-neutral-800 bg-neutral-950/95 backdrop-blur"
>
<div className="mx-auto flex max-w-3xl">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
role="tab"
className={({ isActive }) =>
`flex flex-1 flex-col items-center gap-1 py-2 text-xs ${
isActive ? 'text-emerald-400' : 'text-neutral-400'
}`
}
>
{tab.icon}
{tab.label}
</NavLink>
))}
</div>
</nav>
</div>
)
}

1
web/src/styles.css Normal file
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

105
web/src/types.ts Normal file
View File

@@ -0,0 +1,105 @@
// Contract types — mirrors the Fastify API (plan 1). Do not rename fields.
export interface User {
id: number
username: string
isAdmin: boolean
}
export interface SettingsView {
hasDiscogsToken: boolean
discogsTokenMasked: string | null
subsonicUrl: string | null
subsonicUsername: string | null
hasSubsonicPassword: boolean
}
export interface Candidate {
id: number
artist: string
title: string
year: number | null
formats: string[]
labels: string[]
country: string | null
catno: string | null
thumbUrl: string | null
}
export interface Release extends Candidate {
genres: string[]
tracklist: { position: string; title: string }[]
coverUrl: string | null
barcodes: string[]
}
export interface ReleasePreview {
release: Release
duplicate: boolean
ripMatch: 'ripped' | 'not_ripped' | 'ambiguous'
matchCandidates: { id: number; title: string; artist: string }[]
}
export interface Item {
id: number
discogsReleaseId: number
title: string
artist: string
year: number | null
formats: string[]
genres: string[]
labels: string[]
tracklist: { position: string; title: string }[]
catno: string | null
country: string | null
artworkUrl: string | null
barcodes: string[]
dateAdded: string
ripOverride: boolean | null
ripStatus: 'ripped' | 'not_ripped'
}
export interface CollectionResponse {
items: Item[]
counts: { total: number; ripped: number; notRipped: number }
}
export interface SyncState {
status: 'idle' | 'running' | 'done' | 'error'
error: string | null
lastSyncedAt: string | null
albums: number
}
export interface DigitalAlbum {
id: number
subsonicId: string
title: string
artist: string
}
export interface MatchedAlbum {
id: number
subsonicId: string
lastPlayedAt: string | null
webUrl: string | null
}
export interface Stats {
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
ripRatio: number
formats: { name: string; count: number }[]
decades: { name: string; count: number }[]
topGenres: { name: string; count: number }[]
topArtists: { name: string; count: number }[]
addedByMonth: { month: string; count: number }[]
}
export interface Loan {
id: number
itemId: number
borrower: string
lentAt: string
returnedAt: string | null
}
export interface LoansResponse {
active: Loan[]
history: Loan[]
}
export interface BackupFile {
file: string
sizeBytes: number
createdAt: string
}
export interface ItemDetail extends Item {
matchedAlbum: MatchedAlbum | null
loan: { id: number; borrower: string; lentAt: string } | null
}

92
web/test/add.test.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import AddPage from '../src/pages/AddPage.js'
import { api, ApiError } from '../src/api.js'
import type { Candidate, ReleasePreview } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, lookupSearch: vi.fn(), getReleasePreview: vi.fn(), addToCollection: vi.fn() } }
})
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['Vinyl'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZEN012',
thumbUrl: null,
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: [] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
// The api module is mocked directly, so mocks resolve with parsed bodies.
function jsonOk(body: unknown) {
return Promise.resolve(body)
}
beforeEach(() => {
vi.mocked(api.lookupSearch).mockReset()
vi.mocked(api.getReleasePreview).mockReset()
vi.mocked(api.addToCollection).mockReset()
})
function renderAdd(initialEntry = '/add') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AddPage />
</MemoryRouter>
)
}
describe('AddPage', () => {
it('searches discogs and shows candidate cards', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'cinematic orchestra motion{Enter}')
await waitFor(() =>
expect(api.lookupSearch).toHaveBeenCalledWith('cinematic orchestra motion', undefined)
)
expect(await screen.findByRole('button', { name: /motion/i })).toBeTruthy()
})
it('passes the format filter and pre-filled query from the URL', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [] }) as never)
renderAdd('/add?q=5021592210629')
expect((screen.getByPlaceholderText(/artist and title/i) as HTMLInputElement).value).toBe(
'5021592210629'
)
await userEvent.selectOptions(screen.getByLabelText(/format/i), 'Vinyl')
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
await waitFor(() => expect(api.lookupSearch).toHaveBeenCalledWith('5021592210629', 'Vinyl'))
})
it('shows a not-found message', async () => {
vi.mocked(api.lookupSearch).mockRejectedValue(new ApiError(404, 'not_found'))
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'zzz{Enter}')
await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
})
it('selecting a candidate loads the confirm view and adds', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'motion{Enter}')
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({ releaseId: 1001 }))
})
})

48
web/test/api.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { api, ApiError } from '../src/api'
const fetchMock = vi.fn()
beforeEach(() => vi.stubGlobal('fetch', fetchMock))
afterEach(() => vi.unstubAllGlobals())
function jsonOk(body: unknown) {
return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
}
describe('api additions', () => {
it('stats, loans, backups urls', async () => {
fetchMock.mockImplementation(() => jsonOk({}))
await api.getStats()
expect(fetchMock).toHaveBeenCalledWith('/api/stats', expect.anything())
await api.lendItem(5, 'Bob')
expect(fetchMock).toHaveBeenCalledWith('/api/collection/5/loan', expect.objectContaining({ method: 'POST' }))
await api.returnLoan(7)
expect(fetchMock).toHaveBeenCalledWith('/api/loans/7/return', expect.objectContaining({ method: 'POST' }))
await api.getBackups()
expect(fetchMock).toHaveBeenCalledWith('/api/backups', expect.anything())
expect(api.exportUrl()).toBe('/api/export')
})
it('surfaces subsonic errors as ApiError', async () => {
fetchMock.mockReturnValue(
Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
)
const err = await api.startSync().catch((e) => e)
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).code).toBe('no_subsonic_config')
})
it('bodyless posts do not send a json content-type (Fastify 400s on empty json bodies)', async () => {
fetchMock.mockClear()
fetchMock.mockImplementation(() => jsonOk({ ok: true }))
await api.returnLoan(7)
await api.logout()
await api.triggerBackup()
for (const call of fetchMock.mock.calls) {
const init = call[1] as RequestInit | undefined
const headers = (init?.headers ?? {}) as Record<string, string>
expect(headers['Content-Type']).toBeUndefined()
expect(init?.body).toBeUndefined()
}
})
})

68
web/test/auth.test.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { AuthProvider, useAuth } from '../src/auth'
function Probe() {
const { status, user, setupNeeded } = useAuth()
return (
<div>
<div>status:{status}</div>
{setupNeeded && <div>setup-needed</div>}
{user && <div>user:{user.username}</div>}
</div>
)
}
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
function jsonOnce(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
describe('AuthProvider', () => {
it('reports setupNeeded when the server has no users', async () => {
fetchMock.mockResolvedValueOnce(jsonOnce(200, { needed: true }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
expect(screen.getByText('status:loading')).toBeTruthy()
await waitFor(() => expect(screen.getByText('status:setup')).toBeTruthy())
expect(screen.getByText('setup-needed')).toBeTruthy()
expect(fetchMock).toHaveBeenCalledWith('/api/setup', expect.anything())
})
it('exposes the user when /api/me succeeds', async () => {
fetchMock
.mockResolvedValueOnce(jsonOnce(200, { needed: false }))
.mockResolvedValueOnce(jsonOnce(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
await waitFor(() => expect(screen.getByText('status:authenticated')).toBeTruthy())
expect(screen.getByText('user:sam')).toBeTruthy()
})
it('reports unauthenticated when /api/me is 401', async () => {
fetchMock
.mockResolvedValueOnce(jsonOnce(200, { needed: false }))
.mockResolvedValueOnce(jsonOnce(401, { error: 'unauthorized' }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy())
})
})

243
web/test/item.test.tsx Normal file
View File

@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ItemPage from '../src/pages/ItemPage.js'
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return {
...actual,
api: {
...actual.api,
getItem: vi.fn(),
setRip: vi.fn(),
setMatch: vi.fn(),
deleteItem: vi.fn(),
searchAlbums: vi.fn(),
lendItem: vi.fn(),
returnLoan: vi.fn(),
},
}
})
import { api, ApiError } from '../src/api.js'
const item: ItemDetail = {
id: 1,
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: ['Electronic'],
labels: ['Ninja Tune'],
tracklist: [{ position: '1', title: 'Overture' }],
catno: 'ZENCD012',
country: 'UK',
artworkUrl: '/artwork/abc.jpg',
barcodes: ['5021592210629'],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
matchedAlbum: null,
loan: null,
}
const matched: MatchedAlbum = {
id: 77,
subsonicId: 'alb-1',
lastPlayedAt: '2026-09-01T10:00:00Z',
webUrl: 'http://navidrome.local/app/#/album/alb-1',
}
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
beforeEach(() => {
vi.mocked(api.getItem).mockReset()
vi.mocked(api.getItem).mockResolvedValue(item as never)
vi.mocked(api.setRip).mockReset()
vi.mocked(api.setMatch).mockReset()
vi.mocked(api.deleteItem).mockReset()
vi.mocked(api.searchAlbums).mockReset()
vi.mocked(api.lendItem).mockReset()
vi.mocked(api.returnLoan).mockReset()
})
function renderItem() {
return render(
<MemoryRouter initialEntries={['/item/1']}>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</MemoryRouter>
)
}
describe('ItemPage', () => {
it('renders metadata and the rip-status banner', async () => {
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.getByText('The Cinematic Orchestra')).toBeTruthy()
expect(screen.getByText(/not ripped yet/i)).toBeTruthy()
expect(screen.getByText(/ZENCD012/)).toBeTruthy()
expect(screen.getByText(/5021592210629/)).toBeTruthy()
expect(screen.getByText('Overture')).toBeTruthy()
expect(screen.getByRole('link', { name: /view on discogs/i }).getAttribute('href')).toBe(
'https://www.discogs.com/release/1001'
)
})
it('rip override: mark ripped, then reset to auto', async () => {
vi.mocked(api.setRip).mockResolvedValue({ ...item, ripOverride: true, ripStatus: 'ripped' } as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, true))
await waitFor(() => expect(screen.getByText(/in your digital collection/i)).toBeTruthy())
vi.mocked(api.setRip).mockResolvedValue(item as never)
await userEvent.click(screen.getByRole('button', { name: /reset to auto/i }))
await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, null))
})
it('re-match: search albums and link one', async () => {
vi.mocked(api.searchAlbums).mockResolvedValue({
albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
} as never)
vi.mocked(api.setMatch).mockResolvedValue({ ...item, ripStatus: 'ripped' } as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
const albumRadio = await screen.findByRole('radio', { name: /motion \(remaster\)/i })
await userEvent.click(albumRadio)
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
})
it('re-match refreshes the link state', async () => {
vi.mocked(api.getItem)
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
.mockResolvedValue({ ...rippedItem } as never)
vi.mocked(api.searchAlbums).mockResolvedValue({
albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
} as never)
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
const albumRadio = await screen.findByRole('radio', { name: /motion \(remaster\)/i })
await userEvent.click(albumRadio)
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
await waitFor(() => expect(screen.getByRole('link', { name: /listen in navidrome/i })).toBeTruthy())
})
it('unlink clears the match', async () => {
vi.mocked(api.setMatch).mockResolvedValue(item as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^unlink$/i }))
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, null))
})
it('remove deletes the item and navigates back to the library', async () => {
vi.mocked(api.deleteItem).mockResolvedValue({ ok: true } as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /^remove$/i }))
await userEvent.click(await screen.findByRole('button', { name: /^confirm remove$/i }))
await waitFor(() => expect(api.deleteItem).toHaveBeenCalledWith(1))
await waitFor(() => expect(screen.getByText('library')).toBeTruthy())
})
it('re-match clears the picked album when searching again', async () => {
vi.mocked(api.searchAlbums)
.mockResolvedValueOnce({
albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
} as never)
.mockResolvedValueOnce({
albums: [{ id: 88, subsonicId: 'a2', title: 'Something Else', artist: 'Other Artist' }],
} as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
await userEvent.click(await screen.findByRole('radio', { name: /motion \(remaster\)/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
const linkBtn = await screen.findByRole('button', { name: /^link$/i })
expect(linkBtn).toHaveProperty('disabled', true)
})
it('shows Reset to auto for a manual not-ripped override', async () => {
vi.mocked(api.getItem).mockResolvedValue({ ...item, ripOverride: false, ripStatus: 'not_ripped' } as never)
renderItem()
await waitFor(() => expect(screen.getByText(/not ripped yet \(manually set\)/i)).toBeTruthy())
expect(screen.getByRole('button', { name: /reset to auto/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /mark ripped/i })).toBeTruthy()
})
it('shows an error when a rip toggle fails', async () => {
vi.mocked(api.setRip).mockRejectedValue(new TypeError('fetch failed'))
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
})
it('shows Listen in Navidrome link for a ripped item with a matched album', async () => {
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
renderItem()
const link = await screen.findByRole('link', { name: /listen in navidrome/i })
expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1')
expect(link.getAttribute('target')).toBe('_blank')
})
it('hides the link when matchedAlbum is null even if ripped', async () => {
vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem, matchedAlbum: null } as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
})
it('link hidden when the subsonic url was cleared (webUrl null)', async () => {
vi.mocked(api.getItem).mockResolvedValue({
...item,
ripStatus: 'ripped',
matchedAlbum: { id: 77, subsonicId: 'alb-1', lastPlayedAt: null, webUrl: null },
} as never)
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
})
it('shows last played under the rip banner', async () => {
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
renderItem()
expect(await screen.findByText(/last played/i)).toBeTruthy()
})
it('lend and return flow', async () => {
vi.mocked(api.getItem)
.mockResolvedValueOnce(rippedItem as never)
.mockResolvedValueOnce({ ...rippedItem, loan: { id: 9, borrower: 'Bob', lentAt: '2026-09-03' } } as never)
vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never)
renderItem()
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob'))
vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
})
it('lend failure surfaces the inline message', async () => {
vi.mocked(api.lendItem).mockRejectedValue(new ApiError(409, 'already_on_loan'))
renderItem()
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
await waitFor(() => expect(screen.getByText(/already out to someone/i)).toBeTruthy())
})
})

132
web/test/library.test.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import LibraryPage from '../src/pages/LibraryPage.js'
import type { Item } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, listCollection: vi.fn() } }
})
import { api } from '../src/api.js'
function item(overrides: Partial<Item>): Item {
return {
id: 1,
discogsReleaseId: 100,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: [],
labels: [],
tracklist: [],
catno: null,
country: null,
artworkUrl: null,
barcodes: [],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
...overrides,
}
}
const data = {
items: [
item({ id: 1, ripStatus: 'not_ripped' }),
item({ id: 2, title: 'Blue Lines', artist: 'Massive Attack', formats: ['Vinyl'], ripStatus: 'ripped' }),
item({ id: 3, title: 'Mezzanine', artist: 'Massive Attack', formats: ['Cassette'], ripStatus: 'not_ripped' }),
],
counts: { total: 3, ripped: 1, notRipped: 2 },
}
beforeEach(() => {
vi.mocked(api.listCollection).mockReset()
vi.mocked(api.listCollection).mockResolvedValue(data as never)
})
function renderLibrary() {
return render(
<MemoryRouter>
<LibraryPage />
</MemoryRouter>
)
}
describe('LibraryPage', () => {
it('renders covers with artist and rip badges', async () => {
renderLibrary()
await waitFor(() => expect(screen.getAllByRole('link', { name: /motion/i })).toHaveLength(1))
expect(screen.getByRole('link', { name: /blue lines/i })).toBeTruthy()
expect(screen.getByRole('link', { name: /mezzanine/i })).toBeTruthy()
})
it('passes format and rip filters to the api', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByRole('button', { name: /^vinyl$/i })).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /^vinyl$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'Vinyl' }))
)
await userEvent.click(screen.getByRole('button', { name: /^ripped$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(
expect.objectContaining({ format: 'Vinyl', ripped: 'ripped' })
)
)
})
it('searches by title/artist via the q filter', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByPlaceholderText(/search/i)).toBeTruthy())
await userEvent.type(screen.getByPlaceholderText(/search/i), 'mezz')
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'mezz' }))
)
})
it('shows collection counts', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByText(/3 in collection/i)).toBeTruthy())
expect(screen.getByText(/1 ripped/i)).toBeTruthy()
expect(screen.getByText(/2 not ripped/i)).toBeTruthy()
})
it('artist index sets the search box to the artist name', async () => {
renderLibrary()
const artistBtn = await screen.findByRole('button', { name: /massive attack/i })
await userEvent.click(artistBtn)
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'Massive Attack' }))
)
})
it('shows an empty state when the collection is empty', async () => {
vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
renderLibrary()
await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add')
})
it('header links to stats and queue', async () => {
renderLibrary()
expect(screen.getByRole('link', { name: /stats/i }).getAttribute('href')).toBe('/stats')
expect(screen.getByRole('link', { name: /queue/i }).getAttribute('href')).toBe('/queue')
})
it('on-loan chip filters by loan state', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByRole('button', { name: /^on loan$/i })).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ onLoan: 'true' }))
)
await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.not.objectContaining({ onLoan: expect.anything() }))
)
})
})

26
web/test/pwa.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
const pub = path.resolve(__dirname, '../public')
describe('PWA assets', () => {
it('manifest has required fields and the icon exists', () => {
const manifest = JSON.parse(readFileSync(path.join(pub, 'manifest.webmanifest'), 'utf8')) as {
name: string
display: string
start_url: string
icons: { src: string; sizes: string; type: string }[]
}
expect(manifest.name).toContain('record-shop')
expect(manifest.display).toBe('standalone')
expect(manifest.start_url).toBe('/')
expect(manifest.icons.some((i) => i.src === '/icon.svg')).toBe(true)
expect(() => readFileSync(path.join(pub, 'icon.svg'))).not.toThrow()
})
it('service worker registers a fetch handler', () => {
const sw = readFileSync(path.join(pub, 'sw.js'), 'utf8')
expect(sw).toContain("addEventListener('fetch'")
})
})

56
web/test/queue.test.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import QueuePage from '../src/pages/QueuePage.js'
import type { Item } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, listCollection: vi.fn(), setRip: vi.fn() } }
})
import { api } from '../src/api.js'
function qItem(id: number, title: string, added: string): Item {
return {
id, discogsReleaseId: 1, title, artist: 'Artist', year: 1999, formats: ['CD'], genres: [], labels: [],
tracklist: [], catno: null, country: null, artworkUrl: null, barcodes: [], dateAdded: added,
ripOverride: null, ripStatus: 'not_ripped',
}
}
beforeEach(() => {
vi.mocked(api.listCollection).mockReset()
vi.mocked(api.setRip).mockReset()
})
describe('QueuePage', () => {
it('lists not-ripped items oldest first and marks ripped', async () => {
vi.mocked(api.listCollection).mockResolvedValue({
items: [qItem(1, 'Newest', '2026-08-20'), qItem(2, 'Oldest', '2026-08-01')],
counts: { total: 2, ripped: 0, notRipped: 2 },
} as never)
vi.mocked(api.setRip).mockResolvedValue({} as never)
render(
<MemoryRouter>
<QueuePage />
</MemoryRouter>
)
expect(await screen.findByText('Oldest')).toBeTruthy() // oldest first
const rows = screen.getAllByRole('button', { name: /mark ripped/i })
await userEvent.click(rows[0]!)
await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(2, true))
await waitFor(() => expect(screen.queryByText('Oldest')).toBeNull())
})
it('empty state points back to scanning', async () => {
vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
render(
<MemoryRouter>
<QueuePage />
</MemoryRouter>
)
expect(await screen.findByText(/nothing waiting/i)).toBeTruthy()
expect(screen.getByRole('link', { name: /scan something/i }).getAttribute('href')).toBe('/scan')
})
})

168
web/test/reducer.test.ts Normal file
View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest'
import { scanReducer, INITIAL_SCAN_STATE, type ScanState } from '../src/scan/reducer.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: 'https://img/x.jpg',
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: ['5021592210629'] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
const item: Item = {
id: 1,
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: [],
labels: ['Ninja Tune'],
tracklist: [],
catno: 'ZENCD012',
country: 'UK',
artworkUrl: null,
barcodes: ['5021592210629'],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
}
function stateOf(phase: ScanState['phase']): ScanState {
let state = INITIAL_SCAN_STATE
const steps: Parameters<typeof scanReducer>[1][] = [
{ type: 'DETECT', code: '5021592210629' },
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
{ type: 'SELECT', candidate },
{ type: 'PREVIEW', preview, candidateId: 1001 },
{ type: 'ADD_START' },
{ type: 'ADDED', item },
]
const order: ScanState['phase'][] = ['scan', 'looking', 'candidates', 'confirm', 'confirm', 'confirm', 'added']
const idx = order.indexOf(phase)
if (idx < 0) return state
for (let i = 1; i <= idx; i++) state = scanReducer(state, steps[i - 1]!)
return state
}
describe('scanReducer', () => {
it('DETECT from scan → looking', () => {
const next = scanReducer(INITIAL_SCAN_STATE, { type: 'DETECT', code: '123' })
expect(next).toEqual({ phase: 'looking', code: '123' })
})
it('DETECT is ignored unless scanning (prevents duplicate lookups)', () => {
const looking = stateOf('looking')
expect(scanReducer(looking, { type: 'DETECT', code: '999' })).toBe(looking)
})
it('CANDIDATES from looking → candidates', () => {
const next = scanReducer(stateOf('looking'), {
type: 'CANDIDATES',
code: '5021592210629',
candidates: [candidate],
})
expect(next.phase).toBe('candidates')
})
it('NOT_FOUND from looking → error not_found', () => {
const next = scanReducer(stateOf('looking'), { type: 'NOT_FOUND', code: '123' })
expect(next).toEqual({ phase: 'error', kind: 'not_found', code: '123' })
})
it('ERROR maps kinds', () => {
const next = scanReducer(stateOf('looking'), {
type: 'ERROR',
kind: 'no_discogs_token',
code: '123',
source: 'lookup',
})
expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' })
})
it('ignores a stale preview for a different candidate', () => {
const confirm = stateOf('confirm')
const stale = scanReducer(confirm, { type: 'PREVIEW', preview, candidateId: 999 })
expect(stale).toBe(confirm)
})
it('ignores a preview-source error in the looking phase', () => {
const looking = stateOf('looking')
const next = scanReducer(looking, {
type: 'ERROR', kind: 'server', code: null, source: 'preview', candidateId: 1001,
})
expect(next).toBe(looking)
})
it('ignores a lookup-source error after the user moved past looking', () => {
const candidates = stateOf('candidates')
const next = scanReducer(candidates, {
type: 'ERROR', kind: 'server', code: '123', source: 'lookup',
})
expect(next).toBe(candidates)
})
it('SELECT from candidates → confirm with candidate, no preview yet', () => {
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
expect(next.phase).toBe('confirm')
if (next.phase === 'confirm') {
expect(next.candidate).toBe(candidate)
expect(next.preview).toBeNull()
expect(next.adding).toBe(false)
expect(next.matchAlbumId).toBeNull()
}
})
it('PREVIEW fills the confirm phase', () => {
const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
if (next.phase === 'confirm') expect(next.preview).toBe(preview)
else throw new Error('expected confirm')
})
it('SET_MATCH / CLEAR_MATCH only in confirm', () => {
const confirm = stateOf('confirm')
const set = scanReducer(confirm, { type: 'SET_MATCH', albumId: 7 })
if (set.phase === 'confirm') expect(set.matchAlbumId).toBe(7)
const cleared = scanReducer(set, { type: 'CLEAR_MATCH' })
if (cleared.phase === 'confirm') expect(cleared.matchAlbumId).toBeNull()
expect(scanReducer(stateOf('scan'), { type: 'SET_MATCH', albumId: 7 })).toBe(stateOf('scan'))
})
it('ADD_START → ADDED', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
const added = scanReducer(starting, { type: 'ADDED', item })
expect(added).toEqual({ phase: 'added', item })
})
it('ADD_ERROR stores the message without leaving confirm', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
if (next.phase === 'confirm') {
expect(next.adding).toBe(false)
expect(next.addError).toBe('duplicate')
} else {
throw new Error('expected confirm')
}
})
it('RESET returns to scan from anywhere', () => {
for (const phase of ['looking', 'candidates', 'confirm', 'added', 'error'] as const) {
expect(scanReducer(stateOf(phase), { type: 'RESET' })).toEqual({ phase: 'scan' })
}
})
})

99
web/test/router.test.tsx Normal file
View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from '../src/App'
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
window.history.replaceState(null, '', '/')
})
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function loggedInServer() {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
return Promise.resolve(json(404, { error: 'not_found' }))
})
}
describe('routing', () => {
it('shows the setup form when the server needs setup', async () => {
fetchMock.mockImplementation((url: string) =>
url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {}))
)
render(<App />)
await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy())
})
it('shows the login form when unauthenticated', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
return Promise.resolve(json(401, { error: 'unauthorized' }))
})
render(<App />)
await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy())
})
it('lands on Library with the tab bar when authenticated', async () => {
loggedInServer()
render(<App />)
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
for (const tab of ['Library', 'Scan', 'Add', 'Settings']) {
expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy()
}
})
it('login form authenticates and enters the app', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' }))
if (url === '/api/login') {
return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
}
return Promise.resolve(json(404, {}))
})
render(<App />)
await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
await userEvent.type(screen.getByLabelText(/username/i), 'sam')
await userEvent.type(screen.getByLabelText(/password/i), 'password123')
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
})
it('setup form creates the admin account and enters the app', async () => {
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) {
return Promise.resolve(json(200, { needed: true }))
}
if (url === '/api/setup' && init?.method === 'POST') {
return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } }))
}
return Promise.resolve(json(404, {}))
})
render(<App />)
await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
await userEvent.type(screen.getByLabelText(/username/i), 'boss')
await userEvent.type(screen.getByLabelText(/password/i), 'password123')
await userEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
})
it('tab navigation switches pages', async () => {
loggedInServer()
render(<App />)
await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy())
await userEvent.click(screen.getByRole('tab', { name: /settings/i }))
await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy())
expect(screen.getByText(/account/i)).toBeTruthy()
})
})

188
web/test/scanPage.test.tsx Normal file
View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import ScanPage from '../src/pages/ScanPage.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
vi.mock('../src/components/Scanner.js', () => ({
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
<button type="button" onClick={() => onDetect('5021592210629')}>
fake-scan
</button>
),
}))
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return {
...actual,
api: {
...actual.api,
lookupBarcode: vi.fn(),
getReleasePreview: vi.fn(),
addToCollection: vi.fn(),
},
}
})
import { api, ApiError } from '../src/api.js'
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: null,
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
// The api module is mocked directly, so mocks resolve with parsed bodies.
const addedItem: Item = {
id: 1,
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: [],
labels: ['Ninja Tune'],
tracklist: [],
catno: 'ZENCD012',
country: 'UK',
artworkUrl: null,
barcodes: ['5021592210629'],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
}
function jsonOk(body: unknown) {
return Promise.resolve(body)
}
beforeEach(() => {
vi.mocked(api.lookupBarcode).mockReset()
vi.mocked(api.getReleasePreview).mockReset()
vi.mocked(api.addToCollection).mockReset()
})
function renderScan() {
return render(
<MemoryRouter initialEntries={['/scan']}>
<ScanPage />
</MemoryRouter>
)
}
describe('ScanPage flow', () => {
it('scan → candidates → confirm → added', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk(addedItem) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
expect(screen.getByText('Overture')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledWith({
releaseId: 1001,
barcode: '5021592210629',
})
})
it('shows not-found guidance with a manual-search escape hatch', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /search manually/i }).getAttribute('href')).toBe(
'/add?q=5021592210629'
)
})
it('sends the picked match candidate for ambiguous rip matches', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(
jsonOk({
...preview,
ripMatch: 'ambiguous',
matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }],
}) as never
)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy())
await userEvent.click(screen.getByRole('radio', { name: /somebody else/i }))
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({
releaseId: 1001,
barcode: '5021592210629',
matchAlbumId: 77,
}))
})
it('shows a duplicate warning from the preview', async () => {
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy())
})
it('links to settings when no discogs token is configured', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(409, 'no_discogs_token'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
})
it('does not add twice when the button is clicked rapidly', async () => {
let resolveAdd: (v: unknown) => void = () => {}
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockImplementation(
() => new Promise((resolve) => { resolveAdd = resolve }) as never
)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
const addBtn = await screen.findByRole('button', { name: /add to collection/i })
await userEvent.click(addBtn)
// adding=true → button is disabled ('Adding…'); a second rapid click must not re-fire the request
await userEvent.click(addBtn).catch(() => {})
resolveAdd(addedItem)
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledTimes(1)
})
it('distinguishes an invalid discogs token with a settings link', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(502, 'discogs_auth'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/rejected your token/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
})
})

Some files were not shown because too many files have changed in this diff Show More