Compare commits
76 Commits
25315247bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c8754a5d | |||
| 642ca52c71 | |||
| 165ac96830 | |||
| ff5f4e9133 | |||
| 023557de64 | |||
| 7b7b149928 | |||
| 3edf7574f9 | |||
| 9b2bc22d59 | |||
| b33399c865 | |||
| fc5797d929 | |||
| 47858b1788 | |||
| ece18d125b | |||
| b2711b2b79 | |||
| c8b596bb02 | |||
| ee19bc773c | |||
| 3ce55ba09e | |||
| 3176807268 | |||
| 53b4bc39f5 | |||
| f8f240f00a | |||
| d63bc4bc12 | |||
| ebac55b7a5 | |||
| 9d1291b58f | |||
| 3bdbd8315c | |||
| bc3db9e4f7 | |||
| 024cc6d953 | |||
| 3fa0b02560 | |||
| 21a4c69972 | |||
| f2fff1d7be | |||
| c1852435c8 | |||
| 3efde1a828 | |||
| 6b85f11871 | |||
| 7a58f9e5a0 | |||
| bc62019162 | |||
| 71423c7ab7 | |||
| e912d9e485 | |||
| b87e6abee0 | |||
| b9ba4f0ca6 | |||
| c76d936f1e | |||
| 890e7da3a7 | |||
| 70b7fdbe1b | |||
| bda43ce237 | |||
| fdf200a960 | |||
| 63e8c5f054 | |||
| 26609d8b62 | |||
| b55a1f8cc1 | |||
| 56ffda9e88 | |||
| 39c956e67d | |||
| ba705d546a | |||
| 061f7349b5 | |||
| a3bf420282 | |||
| 875bc1087c | |||
| f1d6320dfe | |||
| 566a9bfc0d | |||
| ded4e17849 | |||
| e8a83eeefb | |||
| c49321ccf1 | |||
| 217c676658 | |||
| c1dd232a2f | |||
| 0faa74ae27 | |||
| 9b52039fde | |||
| dc63db54ba | |||
| 72f3fc8436 | |||
| 3715bb7447 | |||
| b79a72a81b | |||
| a54f56834d | |||
| e94ffb535b | |||
| 6dfaaf465d | |||
| 8bdbe292d5 | |||
| 1c52281feb | |||
| ad8b7cb22a | |||
| f48093319b | |||
| dff7b4826d | |||
| 5545aa31fb | |||
| 01ad6fbb40 | |||
| 52b2cbd6c5 | |||
| 1dfe47f5dd |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
server/dist
|
||||||
|
web/dist
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
server/dist/
|
||||||
|
web/dist/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.env*
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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"]
|
||||||
27
README.md
27
README.md
@@ -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`
|
||||||
|
|||||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
record-shop:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
4104
docs/superpowers/plans/2026-08-29-record-shop-backend.md
Normal file
4104
docs/superpowers/plans/2026-08-29-record-shop-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
3992
docs/superpowers/plans/2026-08-29-record-shop-frontend.md
Normal file
3992
docs/superpowers/plans/2026-08-29-record-shop-frontend.md
Normal file
File diff suppressed because it is too large
Load Diff
2582
docs/superpowers/plans/2026-09-03-wave1.md
Normal file
2582
docs/superpowers/plans/2026-09-03-wave1.md
Normal file
File diff suppressed because it is too large
Load Diff
105
docs/superpowers/plans/2026-09-04-navidrome-link.md
Normal file
105
docs/superpowers/plans/2026-09-04-navidrome-link.md
Normal 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
|
||||||
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal file
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# record-shop — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-08-29
|
||||||
|
**Status:** Approved design, pending implementation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
A self-hosted webapp for cataloguing a physical music collection (vinyl, CDs, cassettes) and tracking what has been ripped into the owner's digital library. The flagship interaction is scanning a physical item's barcode with a phone camera to add it to the collection in seconds, with metadata pulled from Discogs and an automatic check against the digital library.
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
|
||||||
|
- Deployable with `docker compose up -d` and zero required env vars
|
||||||
|
- Adding a barcoded item takes under 15 seconds on a phone
|
||||||
|
- Rip status is visibly accurate for items whose album exists in a Subsonic-compatible library
|
||||||
|
- Usable one-handed on a phone; installable as a PWA
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single Node.js container running a Fastify server that serves:
|
||||||
|
|
||||||
|
- The REST API under `/api/*`
|
||||||
|
- The built React SPA (Vite + Tailwind CSS, mobile-first)
|
||||||
|
|
||||||
|
**Storage:** SQLite via `better-sqlite3` (WAL mode), database file at `/data/record-shop.db`. Artwork fetched from Discogs is proxied through the server and cached on disk at `/data/artwork-cache/`, keyed by Discogs image URL hash.
|
||||||
|
|
||||||
|
**Barcode decoding** happens client-side: the native `BarcodeDetector` API where available (most Android browsers), with a ZXing-wasm fallback for iOS Safari. Supported symbologies: EAN-13, UPC-A, EAN-8.
|
||||||
|
|
||||||
|
**PWA:** manifest + minimal service worker so the app installs to a phone home screen.
|
||||||
|
|
||||||
|
### Dependencies (per-container, no external services)
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP server | Fastify |
|
||||||
|
| Database | SQLite (better-sqlite3, WAL) |
|
||||||
|
| Sessions | httpOnly cookie, server-side session store in SQLite; secret generated at first boot and persisted in `/data` |
|
||||||
|
| Password hashing | argon2 |
|
||||||
|
| Frontend | React + Vite + Tailwind CSS |
|
||||||
|
| Barcode decode | `BarcodeDetector` API, ZXing-wasm fallback |
|
||||||
|
| Discogs access | Personal access token (free), per user |
|
||||||
|
| Digital library | Subsonic API (Navidrome, Gonic, Airsonic, LMS, …) |
|
||||||
|
|
||||||
|
## Users and auth
|
||||||
|
|
||||||
|
- First run shows a setup screen that creates the admin account. Admin creates additional users.
|
||||||
|
- Auth: username + password (argon2), httpOnly session cookie.
|
||||||
|
- Multi-user with **separate private collections**: each user scans into and sees only their own collection.
|
||||||
|
- Per-user settings, stored server-side: Discogs personal access token; Subsonic URL, username, and password.
|
||||||
|
|
||||||
|
## Data model (SQLite)
|
||||||
|
|
||||||
|
- `users` — id, username, password_hash, is_admin, created_at
|
||||||
|
- `settings` — user_id (PK), discogs_token, subsonic_url, subsonic_username, subsonic_password
|
||||||
|
- `collection_items` — id, user_id, discogs_release_id, title, artist, year, formats (JSON), local_artwork_path, barcodes (JSON, may be empty), date_added. Unique on (user_id, discogs_release_id).
|
||||||
|
- `rip_status` on each collection item:
|
||||||
|
- `null` — automatic: derived from library match state
|
||||||
|
- `true` / `false` — manual override, wins over everything
|
||||||
|
- `digital_albums` — id, user_id, subsonic_id, title, artist, synced_at. Per-user cache of the Subsonic library, refreshed by sync.
|
||||||
|
- `match_links` — user_id, collection_item_id, digital_album_id. Manual mapping between a physical item and a digital album, set when fuzzy matching is ambiguous or wrong.
|
||||||
|
|
||||||
|
Discogs metadata (title, artist, year, formats, tracklist, labels, genres, images) is denormalized into `collection_items` and the disk cache at add time, so the collection remains readable even if Discogs is unreachable later.
|
||||||
|
|
||||||
|
## Core flows
|
||||||
|
|
||||||
|
### Scan flow (primary)
|
||||||
|
|
||||||
|
1. Tap the scan tab → camera view opens with a viewfinder overlay guiding the barcode into frame.
|
||||||
|
2. Barcode decoded client-side → `GET /api/lookup/barcode/:code` → server queries Discogs `database/search?barcode=...&type=release`.
|
||||||
|
3. **0 results** → "Nothing found" screen with a one-tap text-search fallback.
|
||||||
|
4. **1+ results** → candidate cards (cover, title, artist, year, format, country). User taps the correct edition.
|
||||||
|
5. Server fetches the full release (`GET /releases/:id`), then:
|
||||||
|
- Runs the rip check against the user's `digital_albums` cache.
|
||||||
|
- Checks for a duplicate: same (user, discogs_release_id) already in the collection → warning shown.
|
||||||
|
6. Confirm screen: cover, metadata, **"In your digital collection ✓ / Not ripped yet ✗"** banner, "Add to collection" button.
|
||||||
|
7. Item added → user lands on its detail page.
|
||||||
|
|
||||||
|
### Text-search flow (fallback for unscannable items)
|
||||||
|
|
||||||
|
Same pipeline from step 4 onward, via `GET /api/lookup/search?q=...`, filterable by format. Entry point on the Add tab. Used for most vinyl and cassettes.
|
||||||
|
|
||||||
|
### Library sync flow
|
||||||
|
|
||||||
|
- "Sync library now" (Settings and Library screens) paginates Subsonic `getAlbumList2`, upserting into `digital_albums`.
|
||||||
|
- Runs in the background with a progress indicator; "last synced X ago" stamp shown.
|
||||||
|
- Saving a Subsonic config triggers a first sync immediately.
|
||||||
|
|
||||||
|
### Rip status resolution (computed per item, cached)
|
||||||
|
|
||||||
|
1. Manual override (`rip_status` non-null) wins, always.
|
||||||
|
2. Else a manual `match_link` decides.
|
||||||
|
3. Else fuzzy auto-match against `digital_albums`: confident match → ripped; no match → not ripped.
|
||||||
|
4. Library re-syncs never clobber manual overrides or confirmed match links.
|
||||||
|
|
||||||
|
**Fuzzy matching rule:** normalize strings (lowercase, strip punctuation, strip leading articles "the"/"a"/"an", collapse whitespace). A **confident match** requires normalized artist and normalized title to be equal. Anything else (near-misses, multiple candidates, no match) is **ambiguous**: the app asks the user to confirm once from a short candidate list, and the confirmed link is stored in `match_links`.
|
||||||
|
|
||||||
|
### Discogs rate limiting
|
||||||
|
|
||||||
|
Authenticated Discogs allows 60 requests/minute. All Discogs calls go through a server-side serializing queue. Full release payloads are cached on disk keyed by release ID, so re-scans and re-visits cost zero Discogs calls.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Bottom tab bar on mobile: **Library · Scan · Add · Settings**. Desktop renders the same app with a wider responsive grid — no separate layout.
|
||||||
|
|
||||||
|
- **Library** — responsive cover grid (3–4 columns on phone, more on desktop). Filter chips: format (All / Vinyl / CD / Cassette) and rip state (All / Ripped / Not ripped). Search box and artist jump-list. Tap a cover → detail.
|
||||||
|
- **Item detail** — large cover; Discogs metadata (label, cat#, year, formats, genres, tracklist); rip-status banner; barcode(s); manual rip toggle; "Re-match" button to fix a wrong match; link to Discogs; edit/remove.
|
||||||
|
- **Add** — camera entry point plus text search.
|
||||||
|
- **Settings** — Discogs and Subsonic config; library sync; admin user management.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Camera permission denied → explanation screen with retry.
|
||||||
|
- Discogs token missing/invalid → persistent banner linking to Settings.
|
||||||
|
- Discogs 429 → queue absorbs bursts; if the user still hits it, a "slow down, retrying" indicator.
|
||||||
|
- Subsonic unreachable → banner on Library/Settings with "Retry sync".
|
||||||
|
- Barcode not found in Discogs (common on older vinyl) → text-search fallback offered.
|
||||||
|
- Failures are always user-visible with plain phrasing; a scan is never silently lost.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Backend (Vitest):** fuzzy matcher (unit); Discogs and Subsonic clients with mocked HTTP; API integration tests against in-memory SQLite; rate-limit queue behavior.
|
||||||
|
- **Frontend (Vitest + Testing Library):** scan-flow state machine (decode → candidates → confirm → added), candidate cards, filters.
|
||||||
|
- **Manual device checklist:** camera/scanning on iOS Safari and Android Chrome — the one component worth testing on real hardware.
|
||||||
|
- `docker build` verified before each release.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Multi-stage Dockerfile: build stage → slim runtime image.
|
||||||
|
- Exposes port `3000`; one volume at `/data`.
|
||||||
|
- `docker-compose.yml` in the repo as the blessed path.
|
||||||
|
- Setup flow after first boot: open browser → create admin → paste Discogs token (and optionally Subsonic config) → start scanning.
|
||||||
|
|
||||||
|
## Explicitly out of scope (v1)
|
||||||
|
|
||||||
|
- Marketplace/wantlist features of Discogs
|
||||||
|
- Per-disc rip granularity (compilations where only some discs are ripped)
|
||||||
|
- Native mobile app
|
||||||
|
- Other music-server APIs (Jellyfin, Plex) — Subsonic first, by design
|
||||||
|
- Multi-collection sharing, household accounts
|
||||||
@@ -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
|
||||||
39
docs/superpowers/specs/2026-09-04-navidrome-link-design.md
Normal file
39
docs/superpowers/specs/2026-09-04-navidrome-link-design.md
Normal 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
5173
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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
89
server/src/app.ts
Normal 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
45
server/src/artwork.ts
Normal 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
66
server/src/auth.ts
Normal 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
42
server/src/config.ts
Normal 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
122
server/src/db.ts
Normal 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
157
server/src/discogs.ts
Normal 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
18
server/src/index.ts
Normal 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
43
server/src/matcher.ts
Normal 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
27
server/src/queue.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
server/src/releaseCache.ts
Normal file
31
server/src/releaseCache.ts
Normal 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
98
server/src/ripstatus.ts
Normal 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
|
||||||
|
}
|
||||||
168
server/src/routes/authRoutes.ts
Normal file
168
server/src/routes/authRoutes.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
245
server/src/routes/collectionRoutes.ts
Normal file
245
server/src/routes/collectionRoutes.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
71
server/src/routes/dataRoutes.ts
Normal file
71
server/src/routes/dataRoutes.ts
Normal 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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
80
server/src/routes/libraryRoutes.ts
Normal file
80
server/src/routes/libraryRoutes.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
64
server/src/routes/loanRoutes.ts
Normal file
64
server/src/routes/loanRoutes.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
87
server/src/routes/lookupRoutes.ts
Normal file
87
server/src/routes/lookupRoutes.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
107
server/src/routes/settingsRoutes.ts
Normal file
107
server/src/routes/settingsRoutes.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
66
server/src/routes/statsRoutes.ts
Normal file
66
server/src/routes/statsRoutes.ts
Normal 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
133
server/src/subsonic.ts
Normal 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
84
server/src/sync.ts
Normal 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
17
server/test/app.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
94
server/test/artwork.test.ts
Normal file
94
server/test/artwork.test.ts
Normal 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
50
server/test/auth.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
179
server/test/authRoutes.test.ts
Normal file
179
server/test/authRoutes.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
265
server/test/collection.test.ts
Normal file
265
server/test/collection.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
46
server/test/config.test.ts
Normal file
46
server/test/config.test.ts
Normal 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
64
server/test/data.test.ts
Normal 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
37
server/test/db.test.ts
Normal 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
119
server/test/discogs.test.ts
Normal 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
45
server/test/fixtures.ts
Normal 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
74
server/test/helpers.ts
Normal 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 } }
|
||||||
|
}
|
||||||
54
server/test/lastplayed.test.ts
Normal file
54
server/test/lastplayed.test.ts
Normal 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
206
server/test/library.test.ts
Normal 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
94
server/test/loans.test.ts
Normal 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
193
server/test/lookup.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
60
server/test/matcher.test.ts
Normal file
60
server/test/matcher.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
62
server/test/migrate.test.ts
Normal file
62
server/test/migrate.test.ts
Normal 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
42
server/test/queue.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
97
server/test/ripstatus.test.ts
Normal file
97
server/test/ripstatus.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
124
server/test/settings.test.ts
Normal file
124
server/test/settings.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
68
server/test/static.test.ts
Normal file
68
server/test/static.test.ts
Normal 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
47
server/test/stats.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
193
server/test/subsonic.test.ts
Normal file
193
server/test/subsonic.test.ts
Normal 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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
5
server/tsconfig.build.json
Normal file
5
server/tsconfig.build.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
5
server/tsconfig.json
Normal file
5
server/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": { "noEmit": true },
|
||||||
|
"include": ["src/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal 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
23
vitest.config.ts
Normal 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
16
web/index.html
Normal 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
7
web/public/icon.svg
Normal 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 |
13
web/public/manifest.webmanifest
Normal file
13
web/public/manifest.webmanifest
Normal 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
11
web/public/sw.js
Normal 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
52
web/src/App.tsx
Normal 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
117
web/src/api.ts
Normal 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
70
web/src/auth.tsx
Normal 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
|
||||||
|
}
|
||||||
26
web/src/components/CandidateCard.tsx
Normal file
26
web/src/components/CandidateCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
web/src/components/Cover.tsx
Normal file
25
web/src/components/Cover.tsx
Normal 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}`} />
|
||||||
|
}
|
||||||
30
web/src/components/CoverGrid.tsx
Normal file
30
web/src/components/CoverGrid.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
web/src/components/Scanner.tsx
Normal file
39
web/src/components/Scanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
web/src/hooks/useBarcodeScanner.ts
Normal file
121
web/src/hooks/useBarcodeScanner.ts
Normal 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
14
web/src/main.tsx
Normal 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
206
web/src/pages/AddPage.tsx
Normal 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
334
web/src/pages/ItemPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
web/src/pages/LibraryPage.tsx
Normal file
141
web/src/pages/LibraryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
web/src/pages/LoginPage.tsx
Normal file
71
web/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
web/src/pages/QueuePage.tsx
Normal file
61
web/src/pages/QueuePage.tsx
Normal 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
213
web/src/pages/ScanPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
326
web/src/pages/SettingsPage.tsx
Normal file
326
web/src/pages/SettingsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
web/src/pages/SetupPage.tsx
Normal file
74
web/src/pages/SetupPage.tsx
Normal 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
111
web/src/pages/StatsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
web/src/scan/ConfirmView.tsx
Normal file
100
web/src/scan/ConfirmView.tsx
Normal 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
81
web/src/scan/reducer.ts
Normal 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
79
web/src/shell.tsx
Normal 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
1
web/src/styles.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
105
web/src/types.ts
Normal file
105
web/src/types.ts
Normal 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
92
web/test/add.test.tsx
Normal 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
48
web/test/api.test.ts
Normal 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
68
web/test/auth.test.tsx
Normal 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
243
web/test/item.test.tsx
Normal 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
132
web/test/library.test.tsx
Normal 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
26
web/test/pwa.test.ts
Normal 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
56
web/test/queue.test.tsx
Normal 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
168
web/test/reducer.test.ts
Normal 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
99
web/test/router.test.tsx
Normal 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
188
web/test/scanPage.test.tsx
Normal 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
Reference in New Issue
Block a user