1
0

Compare commits

..

25 Commits

Author SHA1 Message Date
d3c8754a5d fix: hide navidrome link when webUrl is null (cleared subsonic url) 2026-09-04 14:04:24 +02:00
642ca52c71 feat: navidrome deep link replaces in-app player 2026-09-04 13:45:12 +02:00
165ac96830 feat: matchedAlbum.webUrl from subsonic settings; remove stream proxy routes 2026-09-04 13:37:01 +02:00
ff5f4e9133 docs: navidrome deep-link implementation plan (3 tasks) 2026-09-04 13:27:00 +02:00
023557de64 docs: navidrome deep-link spec (replaces in-app player) 2026-09-04 13:23:49 +02:00
7b7b149928 fix: bodyless posts no longer send json content-type (fastify 400 on empty body) 2026-09-04 13:14:46 +02:00
3edf7574f9 fix: loan error surfacing, non-admin export, guarded atomic migration, expanded player controls 2026-09-03 23:50:54 +02:00
9b2bc22d59 feat: settings data section with export and backups 2026-09-03 23:17:21 +02:00
b33399c865 feat: stats page with css bar charts and rip-ratio donut 2026-09-03 23:08:09 +02:00
fc5797d929 feat: rip queue page, library stats/queue links and on-loan chip 2026-09-03 22:59:31 +02:00
47858b1788 fix: refetch item detail after re-match so the play button stays honest 2026-09-03 22:54:09 +02:00
ece18d125b feat: play button, last played, loan lend/return on item page 2026-09-03 22:45:20 +02:00
b2711b2b79 feat: mini player bar with expandable track list 2026-09-03 22:28:20 +02:00
c8b596bb02 feat: global player context with album queue and audio element 2026-09-03 22:15:19 +02:00
ee19bc773c feat: web api client additions for wave 1 2026-09-03 22:06:57 +02:00
3ce55ba09e feat: json export and sqlite backup endpoints 2026-09-03 21:57:38 +02:00
3176807268 feat: collection stats route 2026-09-03 21:45:52 +02:00
53b4bc39f5 feat: loan tracking routes 2026-09-03 21:37:06 +02:00
f8f240f00a feat: matchedAlbum on item detail, onLoan collection filter 2026-09-03 21:25:47 +02:00
d63bc4bc12 test: permanent coverage for stream/album proxy routes 2026-09-03 21:25:47 +02:00
ebac55b7a5 feat: last-played stamping via sync and played endpoint, stream/album proxy routes 2026-09-03 21:03:03 +02:00
9d1291b58f feat: subsonic getAlbum/getRecentAlbums and stream url builder 2026-09-03 20:53:49 +02:00
3bdbd8315c feat: schema versioning with v2 migration (loans, last_played_at) 2026-09-03 20:43:33 +02:00
bc3db9e4f7 docs: wave 1 implementation plan (15 tasks) 2026-09-03 20:37:38 +02:00
024cc6d953 docs: wave 1 design spec (export/backup, stats, loans, rip queue, listening bridge) 2026-09-03 20:23:40 +02:00
37 changed files with 4399 additions and 34 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,82 @@
# Wave 1 — Hygiene + Listening Bridge — Design Spec
**Date:** 2026-09-03
**Status:** Approved design, pending implementation
**Parent:** docs/superpowers/specs/2026-08-29-record-shop-design.md
**Scope:** Export/backup, stats wall, loan tracking, rip queue, listening bridge (built-in player). MusicBrainz fallback and copies/condition are explicitly deferred (see plan-2 post-implementation decisions).
## Purpose
Turn rip status from a label into a workflow (rip queue), connect the physical collection to listening (built-in player + last-played), protect selfhosted data (export + backups), and surface the collection's shape (stats). All on the existing Fastify/SQLite/React stack with the frozen plan-1 API as the base.
## Schema versioning (prerequisite)
`migrate()` gains versioned steps: `app_meta.schema_version` (absent = v1) and guarded `ALTER TABLE` upgrades. v2 changes:
- `ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT` — null = never played; set during sync
- New table `loans`:
```sql
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
borrower TEXT NOT NULL,
lent_at TEXT NOT NULL DEFAULT (datetime('now')),
returned_at TEXT
);
```
A test migrates a pre-v1 fixture database and asserts both changes. Sync re-runs are idempotent.
## API additions (all require auth unless noted)
| Method | Path | Behavior |
| --- | --- | --- |
| GET | `/api/stream/:songId` | Proxies Subsonic `stream?id=songId` with the user's credentials; `Range` request header passed through, response status/headers (Content-Type, Content-Range, Accept-Ranges) passed back — seeking works. Upstream failure → 502 `stream_unavailable` |
| GET | `/api/album/:subsonicId/tracks` | Proxies `getAlbum` → `{album: {id, title, artist}, tracks: [{id, title, duration, track}]}` ordered by track number |
| GET | `/api/stats` | `{totals: {items, ripped, notRipped, onLoan}, formats: [{name, count}], topGenres: [{name, count}] (≤10), topArtists: [{name, count}] (≤10), addedByMonth: [{month: 'YYYY-MM', count}] (last 12), ripRatio: 0..1}` — computed from the user's items + active loans |
| GET | `/api/export` | `{exportedAt, items: [Item…], loans: […], matchLinks: […]}` — per authenticated user; **excludes settings/secrets** |
| POST | `/api/backup` | Admin only. `better-sqlite3` `db.backup()` to `/data/backups/record-shop-YYYYMMDD-HHMMSS.db`; prunes to newest 7; returns `{file}` |
| GET | `/api/backups` | `{backups: [{file, sizeBytes, createdAt}]}` newest first |
| POST | `/api/collection/:id/loan` | `{borrower}` (non-empty string) → loan row; 400 `invalid_input` if borrower empty; 409 `already_on_loan` if an active loan exists for the item |
| GET | `/api/loans` | `{active: […], history: […]}` (returned loans, newest first, ≤50) |
| POST | `/api/loans/:id/return` | Sets `returned_at`; 404 if missing or already returned |
**Detail enrichment:** `GET /api/collection/:id` response gains `matchedAlbum: {id: number, subsonicId: string, lastPlayedAt: string | null} | null` — resolved with the same order as rip status (override does not affect it; match_link wins, else confident fuzzy match). List responses are unchanged.
**Sync change:** after upserting albums, sync fetches `getAlbumList2?type=recent&size=500` and sets `last_played_at` on matched rows (albums absent from the recent list keep their existing value; never-played stays null).
## Mini player (frontend)
- `PlayerProvider` (context + reducer) mounted above the router: `{tracks, index, playing, error}`. Audio survives navigation — one global `<audio>` element owned by the provider.
- `GET /api/album/:subsonicId/tracks` loads the queue; each track streams from `/api/stream/:trackId`; `ended` auto-advances; last track ends the queue.
- **Mini-bar** fixed above the tab bar when a queue exists: cover thumb, current track title, play/pause, seek bar (client-side over `duration`/`currentTime`). Tap expands to the full player: track list with per-track durations, prev/next, "stream unavailable — skip" affordance on track `error` (jumps to next track, flags the failed one).
- Entry: Item page **Play** button, visible when `ripStatus === 'ripped'` and `matchedAlbum != null`.
## Pages & routing
- **Item page** — Play button; "Last played X ago" (humanized, under the rip banner, hidden when null); loan section: when not on loan → borrower input + "Lend"; when on loan → "Out to {borrower} since {date}" + "Returned" button.
- **Library** — header row adds links **Stats · Queue**; filter chips gain **On loan** (active-loan items).
- **Stats page** (`/stats`) — cards (total, ripped, not ripped, on loan) and pure-CSS bar charts (formats, top genres, top artists, added-by-month over 12 months) + rip-ratio donut (conic-gradient). No chart library.
- **Queue page** (`/queue`) — not-ripped items, oldest `date_added` first; rows: cover, title/artist, "Mark ripped" (PATCH rip `true`, row removes), link into the item page for re-match.
- **Settings** — new Data section: "Export JSON" (browser download of `/api/export`), "Back up now" (POST, shows result), last-backup time + backup list from `/api/backups`.
- Routes `/stats` and `/queue` live inside the protected shell; no new tab bar entries.
## Error handling
- Player track `error` → inline per-track flag + auto/skip affordance; provider never crashes; empty state when queue exhausted.
- Export/backup/loans failures → flash banner (Settings) or inline error (Item page).
- Lend with empty borrower → 400 `invalid_input` (HTML `required` also prevents it client-side); lend an on-loan item → 409 `already_on_loan` shown inline.
## Testing
- **Server (Vitest, node):** stream proxy incl. Range passthrough and 502 mapping (mocked Subsonic); tracks endpoint ordering; stats aggregation on seeded data; loans CRUD + 409 + return; export shape asserting absence of settings/secrets; backup writes to tmp dir + prunes to 7; **migration v1→v2 on a fixture DB** (fixture = pre-v2 schema dump) + sync stamps last_played_at.
- **Web (Vitest, jsdom):** player reducer (load/advance/error/skip), mini-bar + expanded player rendering and controls, queue page mark-ripped flow, stats page rendering, item page play/loan UI, settings data section.
- Manual: audio playback on the phone (codec support, seeking) joins the device checklist.
## Explicitly out of scope (v1)
- Volume control, shuffle/repeat, persistent playback position across sessions
- Offline playback / full offline mode
- CSV export (JSON only), restore-from-backup UI
- Now-playing indicator from other users' sessions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -0,0 +1,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,
}
})
}

View File

@@ -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[]> {

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -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 },
])
})
}) })

View File

@@ -9,6 +9,8 @@ import ItemPage from './pages/ItemPage'
import ScanPage from './pages/ScanPage' import ScanPage from './pages/ScanPage'
import AddPage from './pages/AddPage' import AddPage from './pages/AddPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import StatsPage from './pages/StatsPage'
import QueuePage from './pages/QueuePage'
function Gate({ children }: { children: ReactNode }) { function Gate({ children }: { children: ReactNode }) {
const { status } = useAuth() const { status } = useAuth()
@@ -39,6 +41,8 @@ export default function App() {
<Route path="/scan" element={<ScanPage />} /> <Route path="/scan" element={<ScanPage />} />
<Route path="/add" element={<AddPage />} /> <Route path="/add" element={<AddPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/queue" element={<QueuePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/library" replace />} /> <Route path="*" element={<Navigate to="/library" replace />} />
</Routes> </Routes>

View File

@@ -1,10 +1,15 @@
import type { import type {
BackupFile,
Candidate, Candidate,
CollectionResponse, CollectionResponse,
DigitalAlbum, DigitalAlbum,
Item, Item,
ItemDetail,
Loan,
LoansResponse,
ReleasePreview, ReleasePreview,
SettingsView, SettingsView,
Stats,
SyncState, SyncState,
User, User,
} from './types' } from './types'
@@ -42,8 +47,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
function post<T>(path: string, payload?: unknown): Promise<T> { function post<T>(path: string, payload?: unknown): Promise<T> {
return request<T>(path, { return request<T>(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, ...(payload !== undefined
body: payload === undefined ? undefined : JSON.stringify(payload), ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
: {}),
}) })
} }
@@ -73,17 +79,18 @@ export const api = {
), ),
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`), getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => { listCollection: (params: { format?: string; ripped?: string; q?: string; onLoan?: string } = {}) => {
const usp = new URLSearchParams() const usp = new URLSearchParams()
if (params.format) usp.set('format', params.format) if (params.format) usp.set('format', params.format)
if (params.ripped) usp.set('ripped', params.ripped) if (params.ripped) usp.set('ripped', params.ripped)
if (params.q) usp.set('q', params.q) if (params.q) usp.set('q', params.q)
if (params.onLoan) usp.set('onLoan', params.onLoan)
const qs = usp.toString() const qs = usp.toString()
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`) return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
}, },
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) => addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
post<Item>('/api/collection', body), post<Item>('/api/collection', body),
getItem: (id: number) => request<Item>(`/api/collection/${id}`), getItem: (id: number) => request<ItemDetail>(`/api/collection/${id}`),
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }), deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
setRip: (id: number, ripped: boolean | null) => setRip: (id: number, ripped: boolean | null) =>
request<Item>(`/api/collection/${id}/rip`, { request<Item>(`/api/collection/${id}/rip`, {
@@ -97,4 +104,14 @@ export const api = {
syncStatus: () => request<SyncState>('/api/library/sync'), syncStatus: () => request<SyncState>('/api/library/sync'),
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }), startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`), 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'),
} }

View File

@@ -1,13 +1,27 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../api.js' import { api, ApiError } from '../api.js'
import type { DigitalAlbum, Item } from '../types.js' import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.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() { export default function ItemPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [item, setItem] = useState<Item | null>(null) const [item, setItem] = useState<ItemDetail | null>(null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [matching, setMatching] = useState(false) const [matching, setMatching] = useState(false)
const [albumQuery, setAlbumQuery] = useState('') const [albumQuery, setAlbumQuery] = useState('')
@@ -16,13 +30,19 @@ export default function ItemPage() {
const [confirmRemove, setConfirmRemove] = useState(false) const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState<string | null>(null) const [mutationError, setMutationError] = useState<string | null>(null)
const refetch = useCallback(() => {
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
}, [id])
useEffect(() => { useEffect(() => {
setMutationError(null) setMutationError(null)
void api refetch()
.getItem(Number(id)) }, [refetch])
.then(setItem)
.catch(() => setError(true)) /** Rip/match mutations return a plain Item — keep the detail-only fields. */
}, [id]) function applyUpdated(updated: Item) {
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
}
function searchAlbums() { function searchAlbums() {
void api void api
@@ -38,10 +58,7 @@ export default function ItemPage() {
if (!item) return if (!item) return
void api void api
.setMatch(item.id, albumId) .setMatch(item.id, albumId)
.then((updated) => { .then(() => refetch())
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again.")) .catch(() => setMutationError("That didn't work — check your connection and try again."))
} }
@@ -91,6 +108,20 @@ export default function ItemPage() {
</p> </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>} {mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -101,7 +132,7 @@ export default function ItemPage() {
void api void api
.setRip(item.id, false) .setRip(item.id, false)
.then((updated) => { .then((updated) => {
setItem(updated) applyUpdated(updated)
setMutationError(null) setMutationError(null)
}) })
.catch(() => setMutationError("That didn't work — check your connection and try again.")) .catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -117,7 +148,7 @@ export default function ItemPage() {
void api void api
.setRip(item.id, true) .setRip(item.id, true)
.then((updated) => { .then((updated) => {
setItem(updated) applyUpdated(updated)
setMutationError(null) setMutationError(null)
}) })
.catch(() => setMutationError("That didn't work — check your connection and try again.")) .catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -134,7 +165,7 @@ export default function ItemPage() {
void api void api
.setRip(item.id, null) .setRip(item.id, null)
.then((updated) => { .then((updated) => {
setItem(updated) applyUpdated(updated)
setMutationError(null) setMutationError(null)
}) })
.catch(() => setMutationError("That didn't work — check your connection and try again.")) .catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -209,6 +240,63 @@ export default function ItemPage() {
)} )}
</section> </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 && ( {item.tracklist.length > 0 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open> <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> <summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>

View File

@@ -10,6 +10,7 @@ const RIP = ['All', 'Ripped', 'Not ripped'] as const
export default function LibraryPage() { export default function LibraryPage() {
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All') const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All') const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
const [onLoan, setOnLoan] = useState(false)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [data, setData] = useState<CollectionResponse | null>(null) const [data, setData] = useState<CollectionResponse | null>(null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
@@ -20,6 +21,7 @@ export default function LibraryPage() {
const params = { const params = {
...(format !== 'All' ? { format } : {}), ...(format !== 'All' ? { format } : {}),
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}), ...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
...(onLoan ? { onLoan: 'true' } : {}),
...(q.trim() ? { q: q.trim() } : {}), ...(q.trim() ? { q: q.trim() } : {}),
} }
void api void api
@@ -33,7 +35,7 @@ export default function LibraryPage() {
.catch(() => { .catch(() => {
if (seq.current === mine) setError(true) if (seq.current === mine) setError(true)
}) })
}, [format, ripped, q]) }, [format, ripped, onLoan, q])
const artists = useMemo(() => { const artists = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
@@ -43,6 +45,15 @@ export default function LibraryPage() {
return ( return (
<div className="space-y-4"> <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 <input
type="search" type="search"
placeholder="Search title or artist" placeholder="Search title or artist"
@@ -76,6 +87,15 @@ export default function LibraryPage() {
{r} {r}
</button> </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> </div>
{error && <p className="text-sm text-red-400">Could not load your collection.</p>} {error && <p className="text-sm text-red-400">Could not load your collection.</p>}

View File

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

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNod
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api.js' import { api } from '../api.js'
import { useAuth } from '../auth.js' import { useAuth } from '../auth.js'
import type { SettingsView, SyncState, User } from '../types.js' import type { BackupFile, SettingsView, SyncState, User } from '../types.js'
function Section({ title, children }: { title: string; children: ReactNode }) { function Section({ title, children }: { title: string; children: ReactNode }) {
return ( return (
@@ -29,6 +29,7 @@ export default function SettingsPage() {
const [sync, setSync] = useState<SyncState | null>(null) const [sync, setSync] = useState<SyncState | null>(null)
const [users, setUsers] = useState<User[] | null>(null) const [users, setUsers] = useState<User[] | null>(null)
const [backups, setBackups] = useState<BackupFile[] | null>(null)
const [newUsername, setNewUsername] = useState('') const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -81,6 +82,7 @@ export default function SettingsPage() {
.listUsers() .listUsers()
.then((res) => setUsers(res.users)) .then((res) => setUsers(res.users))
.catch(() => {}) .catch(() => {})
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
} }
}, [user]) }, [user])
@@ -237,6 +239,46 @@ export default function SettingsPage() {
</button> </button>
</Section> </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 && ( {user?.isAdmin && (
<Section title="Users"> <Section title="Users">
<ul className="space-y-1.5 text-sm"> <ul className="space-y-1.5 text-sm">

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

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

View File

@@ -68,3 +68,38 @@ export interface DigitalAlbum {
title: string title: string
artist: 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
}

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

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

View File

@@ -3,19 +3,28 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom' import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ItemPage from '../src/pages/ItemPage.js' import ItemPage from '../src/pages/ItemPage.js'
import type { Item } from '../src/types.js' import type { ItemDetail, MatchedAlbum } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => { vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>() const actual = await importOriginal<typeof import('../src/api.js')>()
return { return {
...actual, ...actual,
api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() }, 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 } from '../src/api.js' import { api, ApiError } from '../src/api.js'
const item: Item = { const item: ItemDetail = {
id: 1, id: 1,
discogsReleaseId: 1001, discogsReleaseId: 1001,
title: 'Motion', title: 'Motion',
@@ -32,8 +41,18 @@ const item: Item = {
dateAdded: '2026-08-29', dateAdded: '2026-08-29',
ripOverride: null, ripOverride: null,
ripStatus: 'not_ripped', 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(() => { beforeEach(() => {
vi.mocked(api.getItem).mockReset() vi.mocked(api.getItem).mockReset()
vi.mocked(api.getItem).mockResolvedValue(item as never) vi.mocked(api.getItem).mockResolvedValue(item as never)
@@ -41,6 +60,8 @@ beforeEach(() => {
vi.mocked(api.setMatch).mockReset() vi.mocked(api.setMatch).mockReset()
vi.mocked(api.deleteItem).mockReset() vi.mocked(api.deleteItem).mockReset()
vi.mocked(api.searchAlbums).mockReset() vi.mocked(api.searchAlbums).mockReset()
vi.mocked(api.lendItem).mockReset()
vi.mocked(api.returnLoan).mockReset()
}) })
function renderItem() { function renderItem() {
@@ -94,6 +115,27 @@ describe('ItemPage', () => {
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77)) 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 () => { it('unlink clears the match', async () => {
vi.mocked(api.setMatch).mockResolvedValue(item as never) vi.mocked(api.setMatch).mockResolvedValue(item as never)
renderItem() renderItem()
@@ -142,4 +184,60 @@ describe('ItemPage', () => {
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i })) await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy()) 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())
})
}) })

View File

@@ -110,4 +110,23 @@ describe('LibraryPage', () => {
await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy()) await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add') 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() }))
)
})
}) })

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

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

View File

@@ -20,6 +20,8 @@ vi.mock('../src/api.js', async (importOriginal) => {
createUser: vi.fn(), createUser: vi.fn(),
deleteUser: vi.fn(), deleteUser: vi.fn(),
logout: vi.fn(), logout: vi.fn(),
triggerBackup: vi.fn(),
getBackups: vi.fn(),
}, },
} }
}) })
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
} }
beforeEach(() => { beforeEach(() => {
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) { 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(fn).mockReset()
} }
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never) vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never) vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never) vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
stubAuthFetch(admin) stubAuthFetch(admin)
}) })
@@ -179,4 +182,30 @@ describe('SettingsPage', () => {
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 }) await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3) 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()
})
}) })

83
web/test/stats.test.tsx Normal file
View 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()
})
})