Compare commits
44 Commits
b55a1f8cc1
...
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 |
@@ -12,7 +12,8 @@ RUN npm ci
|
|||||||
|
|
||||||
COPY tsconfig.json vitest.config.ts ./
|
COPY tsconfig.json vitest.config.ts ./
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
RUN npm run build:server && npm test
|
COPY web ./web
|
||||||
|
RUN npm run build:server && npm run build:web && npm test
|
||||||
RUN npm prune --omit=dev
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
@@ -23,6 +24,7 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ token and (optionally) your Subsonic server details in Settings.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # vitest
|
npm test # vitest (server + web)
|
||||||
npm run dev:server
|
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`
|
Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`
|
||||||
|
|||||||
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
|
||||||
@@ -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)
|
||||||
2305
package-lock.json
generated
2305
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -3,29 +3,48 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=20" },
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx watch server/src/index.ts",
|
"dev:server": "tsx watch server/src/index.ts",
|
||||||
"dev": "npm run dev:server",
|
"dev:web": "vite --config web/vite.config.ts",
|
||||||
|
"dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build:server": "tsc -p server/tsconfig.build.json",
|
"build:server": "tsc -p server/tsconfig.build.json",
|
||||||
"build": "npm run build:server",
|
"build:web": "vite build --config web/vite.config.ts",
|
||||||
|
"build": "npm run build:server && npm run build:web",
|
||||||
"start": "node server/dist/index.js",
|
"start": "node server/dist/index.js",
|
||||||
"typecheck": "tsc -p server/tsconfig.json --noEmit"
|
"typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p web --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/better-sqlite3": "^7.6.11",
|
||||||
"@types/node": "^22.5.0",
|
"@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",
|
"tsx": "^4.16.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^7.3.6",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
|||||||
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
||||||
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
||||||
import { registerLibraryRoutes } from './routes/libraryRoutes.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' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -52,6 +55,9 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerLookupRoutes(app)
|
await registerLookupRoutes(app)
|
||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
await registerLibraryRoutes(app)
|
await registerLibraryRoutes(app)
|
||||||
|
await registerLoanRoutes(app)
|
||||||
|
await registerStatsRoutes(app)
|
||||||
|
await registerDataRoutes(app)
|
||||||
|
|
||||||
// artwork cache (always available)
|
// artwork cache (always available)
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import crypto from 'node:crypto'
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
dataDir: string
|
dataDir: string
|
||||||
artworkDir: string
|
artworkDir: string
|
||||||
|
backupsDir: string
|
||||||
dbPath: string
|
dbPath: string
|
||||||
port: number
|
port: number
|
||||||
sessionSecret: string
|
sessionSecret: string
|
||||||
@@ -15,9 +16,12 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
|||||||
mkdirSync(dataDir, { recursive: true })
|
mkdirSync(dataDir, { recursive: true })
|
||||||
const artworkDir = path.join(dataDir, 'artwork-cache')
|
const artworkDir = path.join(dataDir, 'artwork-cache')
|
||||||
mkdirSync(artworkDir, { recursive: true })
|
mkdirSync(artworkDir, { recursive: true })
|
||||||
|
const backupsDir = path.join(dataDir, 'backups')
|
||||||
|
mkdirSync(backupsDir, { recursive: true })
|
||||||
return {
|
return {
|
||||||
dataDir,
|
dataDir,
|
||||||
artworkDir,
|
artworkDir,
|
||||||
|
backupsDir,
|
||||||
dbPath: path.join(dataDir, 'record-shop.db'),
|
dbPath: path.join(dataDir, 'record-shop.db'),
|
||||||
port: Number(env.PORT ?? 3000),
|
port: Number(env.PORT ?? 3000),
|
||||||
sessionSecret: getOrCreateSecret(dataDir),
|
sessionSecret: getOrCreateSecret(dataDir),
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export function openDatabase(path: string): DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrate(db: DB): void {
|
const BASE_SCHEMA = `
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS app_meta (
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -76,5 +75,48 @@ export function migrate(db: DB): void {
|
|||||||
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
|
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (user_id, item_id)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,3 +62,37 @@ export function resolveRipStatusBatch(
|
|||||||
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import type { DB } from '../db.js'
|
import type { DB } from '../db.js'
|
||||||
import { cacheArtwork } from '../artwork.js'
|
import { cacheArtwork } from '../artwork.js'
|
||||||
import { resolveRipStatus, resolveRipStatusBatch, type RipStatus } from '../ripstatus.js'
|
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
|
||||||
import { requireAuth } from './authRoutes.js'
|
import { requireAuth } from './authRoutes.js'
|
||||||
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
||||||
|
import { getSettings } from './settingsRoutes.js'
|
||||||
|
|
||||||
interface ItemRow {
|
interface ItemRow {
|
||||||
id: number
|
id: number
|
||||||
@@ -146,7 +147,12 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string }
|
const { format, ripped, q, onLoan } = request.query as {
|
||||||
|
format?: string
|
||||||
|
ripped?: string
|
||||||
|
q?: string
|
||||||
|
onLoan?: string
|
||||||
|
}
|
||||||
let filtered = items
|
let filtered = items
|
||||||
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
||||||
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
||||||
@@ -158,6 +164,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
(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 }
|
return { items: filtered, counts }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,7 +180,20 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
const db = request.server.db
|
const db = request.server.db
|
||||||
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' })
|
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||||||
return rowToItem(db, row)
|
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) => {
|
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
|||||||
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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -75,6 +75,40 @@ export class SubsonicClient {
|
|||||||
await this.request('ping')
|
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(
|
async getAllAlbums(
|
||||||
onProgress?: (albums: SubsonicAlbum[], done: number) => void
|
onProgress?: (albums: SubsonicAlbum[], done: number) => void
|
||||||
): Promise<SubsonicAlbum[]> {
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ export class SyncManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
apply()
|
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, {
|
this.states.set(userId, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { discogsReleaseFixture } from './fixtures.js'
|
|||||||
function discogsStub(): typeof fetch {
|
function discogsStub(): typeof fetch {
|
||||||
return (async (input: any) => {
|
return (async (input: any) => {
|
||||||
const url = String(input)
|
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')) {
|
if (url.includes('/releases/1001')) {
|
||||||
return new Response(JSON.stringify(discogsReleaseFixture), {
|
return new Response(JSON.stringify(discogsReleaseFixture), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -212,4 +218,48 @@ describe('collection routes', () => {
|
|||||||
expect(list.json().items).toHaveLength(0)
|
expect(list.json().items).toHaveLength(0)
|
||||||
await app.close()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,11 +9,14 @@ import type { FastifyInstance } from 'fastify'
|
|||||||
export function testConfig(): Config {
|
export function testConfig(): Config {
|
||||||
// artworkDir must be a real writable directory (cached images land there)
|
// artworkDir must be a real writable directory (cached images land there)
|
||||||
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
|
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)
|
// dataDir must be a real writable directory (release-cache payloads land there)
|
||||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
|
||||||
return {
|
return {
|
||||||
dataDir,
|
dataDir,
|
||||||
artworkDir,
|
artworkDir,
|
||||||
|
backupsDir,
|
||||||
dbPath: ':memory:',
|
dbPath: ':memory:',
|
||||||
port: 0,
|
port: 0,
|
||||||
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { openDatabase, type DB } from '../src/db.js'
|
import { openDatabase, type DB } from '../src/db.js'
|
||||||
import { resolveRipStatus } from '../src/ripstatus.js'
|
import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js'
|
||||||
|
|
||||||
describe('resolveRipStatus', () => {
|
describe('resolveRipStatus', () => {
|
||||||
let db: DB
|
let db: DB
|
||||||
@@ -56,3 +56,42 @@ describe('resolveRipStatus', () => {
|
|||||||
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -118,4 +118,76 @@ describe('SubsonicClient', () => {
|
|||||||
{ id: '2', title: 'Album 2', 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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { defineConfig } from 'vitest/config'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['server/test/**/*.test.ts'],
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
88
web/test/scanner.test.ts
Normal file
88
web/test/scanner.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js'
|
||||||
|
|
||||||
|
function fakeStream(): MediaStream {
|
||||||
|
const tracks: MediaStreamTrack[] = [{ stop: vi.fn() } as unknown as MediaStreamTrack]
|
||||||
|
return { getTracks: () => tracks } as unknown as MediaStream
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubMediaDevices(impl: () => Promise<MediaStream>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'navigator',
|
||||||
|
Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, {
|
||||||
|
mediaDevices: { getUserMedia: impl },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldEmit (cooldown dedup)', () => {
|
||||||
|
it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => {
|
||||||
|
expect(shouldEmit(null, '123', 0, 1500)).toBe(true)
|
||||||
|
expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false)
|
||||||
|
expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true)
|
||||||
|
expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useBarcodeScanner', () => {
|
||||||
|
it('detects a barcode via BarcodeDetector and calls onDetect', async () => {
|
||||||
|
const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]]
|
||||||
|
let call = 0
|
||||||
|
class FakeDetector {
|
||||||
|
constructor(_opts: { formats: string[] }) {
|
||||||
|
expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8'])
|
||||||
|
}
|
||||||
|
async detect() {
|
||||||
|
return detections[Math.min(call++, 1)]!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector
|
||||||
|
stubMediaDevices(() => Promise.resolve(fakeStream()))
|
||||||
|
|
||||||
|
const onDetect = vi.fn()
|
||||||
|
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||||
|
const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true))
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||||
|
await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports denied when camera permission is rejected', async () => {
|
||||||
|
stubMediaDevices(() =>
|
||||||
|
Promise.reject(new DOMException('denied', 'NotAllowedError'))
|
||||||
|
)
|
||||||
|
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||||
|
const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true))
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('denied'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops the stream when enabled goes false', async () => {
|
||||||
|
const stream = fakeStream()
|
||||||
|
const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop')
|
||||||
|
stubMediaDevices(() => Promise.resolve(stream))
|
||||||
|
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class {
|
||||||
|
async detect() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled),
|
||||||
|
{ initialProps: { enabled: true } }
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||||
|
rerender({ enabled: false })
|
||||||
|
expect(stopSpy).toHaveBeenCalled()
|
||||||
|
expect(result.current.status).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
211
web/test/settings.test.tsx
Normal file
211
web/test/settings.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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 { AuthProvider } from '../src/auth.js'
|
||||||
|
import SettingsPage from '../src/pages/SettingsPage.js'
|
||||||
|
import type { SettingsView, SyncState, User } 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,
|
||||||
|
getSettings: vi.fn(),
|
||||||
|
putSettings: vi.fn(),
|
||||||
|
syncStatus: vi.fn(),
|
||||||
|
startSync: vi.fn(),
|
||||||
|
listUsers: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
triggerBackup: vi.fn(),
|
||||||
|
getBackups: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
|
const emptyView: SettingsView = {
|
||||||
|
hasDiscogsToken: false,
|
||||||
|
discogsTokenMasked: null,
|
||||||
|
subsonicUrl: null,
|
||||||
|
subsonicUsername: null,
|
||||||
|
hasSubsonicPassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleSync: SyncState = { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
|
||||||
|
|
||||||
|
const admin: User = { id: 1, username: 'sam', isAdmin: true }
|
||||||
|
|
||||||
|
function jsonOk(body: unknown) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthProvider reads /api/setup and /api/me via fetch; stub an authed session
|
||||||
|
function stubAuthFetch(user: User) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url === '/api/setup') return jsonOk({ needed: false })
|
||||||
|
return jsonOk({ user })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout, api.triggerBackup, api.getBackups] as const) {
|
||||||
|
vi.mocked(fn).mockReset()
|
||||||
|
}
|
||||||
|
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
||||||
|
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
||||||
|
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
||||||
|
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
|
||||||
|
stubAuthFetch(admin)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<SettingsPage />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SettingsPage', () => {
|
||||||
|
it('saves the discogs token and shows the mask', async () => {
|
||||||
|
vi.mocked(api.putSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/discogs token/i), 'abcdef0123456789')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save discogs/i }))
|
||||||
|
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: 'abcdef0123456789' }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/token saved/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the discogs token with an empty save', async () => {
|
||||||
|
vi.mocked(api.getSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
|
||||||
|
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /save discogs/i }))
|
||||||
|
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: '' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves subsonic config and surfaces validation errors', async () => {
|
||||||
|
const err = new (await import('../src/api.js')).ApiError(400, 'subsonic_unreachable', 'could not reach http://x')
|
||||||
|
vi.mocked(api.putSettings).mockRejectedValue(err)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://x')
|
||||||
|
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
|
||||||
|
await userEvent.type(screen.getByLabelText(/subsonic password/i), 'pass')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/could not reach http:\/\/x/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows sync state and triggers a sync', async () => {
|
||||||
|
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
|
||||||
|
vi.mocked(api.syncStatus).mockResolvedValue({
|
||||||
|
status: 'done',
|
||||||
|
error: null,
|
||||||
|
lastSyncedAt: '2026-08-29T12:00:00.000Z',
|
||||||
|
albums: 503,
|
||||||
|
} as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
|
||||||
|
await waitFor(() => expect(api.startSync).toHaveBeenCalled())
|
||||||
|
await waitFor(() => expect(screen.getByText(/503 albums/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin manages users', async () => {
|
||||||
|
vi.mocked(api.createUser).mockResolvedValue({ id: 2, username: 'bob', isAdmin: false } as never)
|
||||||
|
renderSettings()
|
||||||
|
expect((await screen.findAllByText('sam')).length).toBeGreaterThan(0)
|
||||||
|
await userEvent.type(screen.getByLabelText(/new username/i), 'bob')
|
||||||
|
await userEvent.type(screen.getByLabelText(/new password/i), 'bobpass123')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add user/i }))
|
||||||
|
await waitFor(() => expect(api.createUser).toHaveBeenCalledWith('bob', 'bobpass123'))
|
||||||
|
expect(await screen.findByText('bob')).toBeTruthy()
|
||||||
|
|
||||||
|
vi.mocked(api.deleteUser).mockResolvedValue({ ok: true } as never)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /remove bob/i }))
|
||||||
|
await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides user management from non-admins', async () => {
|
||||||
|
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByText('bob')).toBeTruthy()
|
||||||
|
expect(screen.queryByLabelText(/discogs token/i)).toBeTruthy()
|
||||||
|
expect(screen.queryByLabelText(/new username/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout button calls the api', async () => {
|
||||||
|
vi.mocked(api.logout).mockResolvedValue({ ok: true } as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
|
||||||
|
expect(api.logout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sync now surfaces the no-config error', async () => {
|
||||||
|
const err = new (await import('../src/api.js')).ApiError(409, 'no_subsonic_config')
|
||||||
|
vi.mocked(api.startSync).mockRejectedValue(err)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/no_subsonic_config/)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the stored subsonic password when saving with a blank password field', async () => {
|
||||||
|
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://navidrome.local')
|
||||||
|
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
|
||||||
|
// password left blank
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.putSettings).toHaveBeenCalledWith({ subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls sync status until it finishes after sync now', async () => {
|
||||||
|
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
|
||||||
|
vi.mocked(api.syncStatus)
|
||||||
|
.mockResolvedValueOnce({ ...idleSync } as never) // mount poll: idle, chain ends
|
||||||
|
.mockResolvedValueOnce({ ...idleSync, status: 'running' } as never) // first poll after sync now
|
||||||
|
.mockResolvedValue({ ...idleSync, status: 'done', albums: 42, lastSyncedAt: '2026-08-29T12:00:00.000Z' } as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
||||||
|
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('data section: export link, backup now, backups list', async () => {
|
||||||
|
vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never)
|
||||||
|
vi.mocked(api.getBackups).mockResolvedValue({
|
||||||
|
backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }],
|
||||||
|
} as never)
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /back up now/i }))
|
||||||
|
await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled())
|
||||||
|
await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backup failure surfaces in the flash banner', async () => {
|
||||||
|
vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed'))
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-admin sees the Export link but not backups', async () => {
|
||||||
|
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('link', { name: /export json/i })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: /back up now/i })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
6
web/test/setup.ts
Normal file
6
web/test/setup.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { afterEach } from 'vitest'
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
31
web/test/smoke.test.tsx
Normal file
31
web/test/smoke.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the app title', async () => {
|
||||||
|
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, {}))
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy())
|
||||||
|
})
|
||||||
|
})
|
||||||
83
web/test/stats.test.tsx
Normal file
83
web/test/stats.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import StatsPage from '../src/pages/StatsPage.js'
|
||||||
|
import type { Stats } 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, getStats: vi.fn() } }
|
||||||
|
})
|
||||||
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
|
const stats: Stats = {
|
||||||
|
totals: { items: 4, ripped: 1, notRipped: 3, onLoan: 1 },
|
||||||
|
ripRatio: 0.25,
|
||||||
|
formats: [
|
||||||
|
{ name: 'CD', count: 3 },
|
||||||
|
{ name: 'Vinyl', count: 1 },
|
||||||
|
],
|
||||||
|
decades: [{ name: '1990s', count: 4 }],
|
||||||
|
topGenres: [
|
||||||
|
{ name: 'Electronic', count: 3 },
|
||||||
|
{ name: 'Downtempo', count: 2 },
|
||||||
|
],
|
||||||
|
topArtists: [{ name: 'Massive Attack', count: 2 }],
|
||||||
|
addedByMonth: [
|
||||||
|
{ month: '2025-10', count: 0 },
|
||||||
|
{ month: '2025-11', count: 0 },
|
||||||
|
{ month: '2025-12', count: 0 },
|
||||||
|
{ month: '2026-01', count: 0 },
|
||||||
|
{ month: '2026-02', count: 0 },
|
||||||
|
{ month: '2026-03', count: 0 },
|
||||||
|
{ month: '2026-04', count: 0 },
|
||||||
|
{ month: '2026-05', count: 0 },
|
||||||
|
{ month: '2026-06', count: 0 },
|
||||||
|
{ month: '2026-07', count: 0 },
|
||||||
|
{ month: '2026-08', count: 4 },
|
||||||
|
{ month: '2026-09', count: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.getStats).mockReset()
|
||||||
|
vi.mocked(api.getStats).mockResolvedValue(stats as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('StatsPage', () => {
|
||||||
|
it('renders totals and rip ratio', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect((await screen.findAllByText('4')).length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(screen.getByText(/25% ripped/i)).toBeTruthy()
|
||||||
|
const onLoanLabel = screen.getByText('on loan')
|
||||||
|
expect(within(onLoanLabel.parentElement as HTMLElement).getByText('1')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders format, genre, artist and month bars', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('CD')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Electronic')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Massive Attack')).toBeTruthy()
|
||||||
|
expect(screen.getByText('1990s')).toBeTruthy()
|
||||||
|
// month bars: 12 bars rendered
|
||||||
|
expect(document.querySelectorAll('[data-bar]').length).toBeGreaterThanOrEqual(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error state on failure', async () => {
|
||||||
|
vi.mocked(api.getStats).mockRejectedValue(new TypeError('fetch failed'))
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText(/could not load stats/i)).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "vite.config.ts"]
|
||||||
|
}
|
||||||
17
web/vite.config.ts
Normal file
17
web/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
root: import.meta.dirname,
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/artwork': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user