1
0

Compare commits

...

19 Commits

Author SHA1 Message Date
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
41 changed files with 4962 additions and 37 deletions

File diff suppressed because it is too large Load Diff

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

@@ -14,6 +14,10 @@ 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 { registerStreamRoutes } from './routes/streamRoutes.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 +56,10 @@ 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 registerStreamRoutes(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,7 +1,7 @@
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'
@@ -146,7 +146,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 +163,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 +179,14 @@ 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
return {
...rowToItem(db, row),
matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id),
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

@@ -0,0 +1,65 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient, SubsonicError } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'
function clientFor(request: any): SubsonicClient | null {
const s = getSettings(request.server.db, request.user.id)
if (!subsonicConfigComplete(s)) return null
return new SubsonicClient({
url: s.subsonic_url as string,
username: s.subsonic_username as string,
password: s.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
}
export async function registerStreamRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/stream/:songId', { preHandler: [requireAuth] }, async (request, reply) => {
const client = clientFor(request)
if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
const songId = (request.params as { songId: string }).songId
const range = request.headers.range
let upstream: Response
try {
upstream = await request.server.fetchImpl(client.url('stream', { id: songId }), {
headers: range ? { Range: range } : {},
})
} catch {
return reply.code(502).send({ error: 'stream_unavailable' })
}
if (!upstream.ok && upstream.status !== 206) {
return reply.code(502).send({ error: 'stream_unavailable' })
}
const headers: Record<string, string> = {}
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
const v = upstream.headers.get(h)
if (v) headers[h] = v
}
if (!upstream.headers.get('accept-ranges')) headers['accept-ranges'] = 'bytes'
return reply.code(upstream.status).headers(headers).send(upstream.body)
})
app.get('/api/album/:subsonicId/tracks', { preHandler: [requireAuth] }, async (request, reply) => {
const client = clientFor(request)
if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
try {
const album = await client.getAlbum((request.params as { subsonicId: string }).subsonicId)
return album
} catch (err) {
if (err instanceof SubsonicError) return reply.code(502).send({ error: 'subsonic_error', detail: err.message })
return reply.code(502).send({ error: 'subsonic_unreachable' })
}
})
app.post('/api/album/:subsonicId/played', { preHandler: [requireAuth] }, async (request, reply) => {
const subsonicId = (request.params as { subsonicId: string }).subsonicId
const info = request.server.db
.prepare(
'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
)
.run(new Date().toISOString(), request.user!.id, subsonicId)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
}

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

@@ -212,4 +212,39 @@ 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: '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', lastPlayedAt: null })
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,84 @@
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()
})
it('played endpoint stamps now and returns 404 for unknown albums', async () => {
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' }] },
})
const before = (
app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
last_played_at: string | null
}
).last_played_at
expect(before).toBeNull()
const res = await app.inject({ method: 'POST', url: '/api/album/a1/played', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const after = (
app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
last_played_at: string | null
}
).last_played_at
expect(after).toBeTruthy()
const missing = await app.inject({ method: 'POST', url: '/api/album/zzz/played', ...auth(cookie) })
expect(missing.statusCode).toBe(404)
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()
})
})

203
server/test/stream.test.ts Normal file
View File

@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js'
const PASSWORD = 's3cret-pass-xyz'
const AUDIO = new TextEncoder().encode('fake-audio-payload')
interface UpstreamRequest {
url: URL
range: string | null
}
interface StubOptions {
album?: () => Response
stream?: (req: UpstreamRequest) => Response
onStream?: (req: UpstreamRequest) => void
}
/** Subsonic stub: ping/getAlbumList2 always ok (settings PUT + sync); album/stream configurable. */
function subsonicStub(opts: StubOptions): typeof fetch {
return (async (input: any, init?: 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')) {
return new Response(
JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [] } } }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
if (url.pathname.endsWith('/rest/getAlbum')) {
return opts.album ? opts.album() : new Response('nope', { status: 404 })
}
if (url.pathname.endsWith('/rest/stream')) {
const req: UpstreamRequest = { url, range: init?.headers?.Range ?? null }
opts.onStream?.(req)
return opts.stream ? opts.stream(req) : new Response('nope', { status: 404 })
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithSubsonic(fetchImpl: typeof fetch) {
const app = await buildTestAppWithDb(openDatabase(':memory:'), fetchImpl)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: PASSWORD },
})
return { app, cookie }
}
// Note: the played endpoint is covered in lastplayed.test.ts — this file
// focuses on the stream/album proxy behaviour.
describe('stream/album proxy routes', () => {
it('forwards the Range header to the stream upstream', async () => {
const seen: UpstreamRequest[] = []
const { app, cookie } = await appWithSubsonic(
subsonicStub({
onStream: (req) => {
seen.push(req)
},
stream: () =>
new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }),
})
)
const res = await app.inject({
method: 'GET',
url: '/api/stream/song-1',
...auth(cookie),
headers: { range: 'bytes=100-' },
})
expect(res.statusCode).toBe(200)
expect(seen[0]?.range).toBe('bytes=100-')
expect(res.body).toBe('fake-audio-payload')
await app.close()
})
it('passes through an upstream 206 partial response with content-range', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
stream: () =>
new Response(AUDIO, {
status: 206,
headers: { 'content-type': 'audio/mpeg', 'content-range': 'bytes 100-116/500' },
}),
})
)
const res = await app.inject({
method: 'GET',
url: '/api/stream/song-1',
...auth(cookie),
headers: { range: 'bytes=100-' },
})
expect(res.statusCode).toBe(206)
expect(res.headers['content-type']).toBe('audio/mpeg')
expect(res.headers['content-range']).toBe('bytes 100-116/500')
expect(res.body).toBe('fake-audio-payload')
await app.close()
})
it('returns 502 stream_unavailable when the upstream fetch throws', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
stream: () => {
throw new Error('upstream down')
},
})
)
const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'stream_unavailable' })
await app.close()
})
it('leaks no subsonic credentials in body or headers (token auth upstream)', async () => {
const upstreamUrls: URL[] = []
const { app, cookie } = await appWithSubsonic(
subsonicStub({
onStream: (req) => {
upstreamUrls.push(req.url)
},
stream: () =>
new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }),
})
)
const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(res.body).not.toContain(PASSWORD)
expect(JSON.stringify(res.headers)).not.toContain(PASSWORD)
// upstream request used token auth — raw password never sent
const upstreamUrl = upstreamUrls[0]
expect(upstreamUrl?.searchParams.get('u')).toBe('sam')
expect(upstreamUrl?.searchParams.get('t')).toMatch(/^[0-9a-f]{32}$/)
expect(upstreamUrl?.searchParams.has('p')).toBe(false)
expect(upstreamUrl?.toString()).not.toContain(PASSWORD)
await app.close()
})
it('album tracks endpoint returns the getAlbum payload with ordered tracks', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
album: () =>
new Response(
JSON.stringify({
'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 },
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
),
})
)
const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(res.json()).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 },
],
})
await app.close()
})
it('returns 502 subsonic_error when subsonic rejects the credentials', async () => {
const { app, cookie } = await appWithSubsonic(
subsonicStub({
album: () =>
new Response(
JSON.stringify({
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password' },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
),
})
)
const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json().error).toBe('subsonic_error')
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

@@ -2,6 +2,8 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AuthProvider, useAuth } from './auth' import { AuthProvider, useAuth } from './auth'
import Shell from './shell' import Shell from './shell'
import { PlayerProvider } from './player/PlayerContext.js'
import MiniBar from './player/MiniBar.js'
import SetupPage from './pages/SetupPage' import SetupPage from './pages/SetupPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import LibraryPage from './pages/LibraryPage' import LibraryPage from './pages/LibraryPage'
@@ -9,6 +11,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()
@@ -30,7 +34,10 @@ export default function App() {
<Route <Route
element={ element={
<Gate> <Gate>
<PlayerProvider>
<Shell /> <Shell />
<MiniBar />
</PlayerProvider>
</Gate> </Gate>
} }
> >
@@ -39,6 +46,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,16 @@
import type { import type {
AlbumTracks,
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'
@@ -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,18 @@ 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)}`),
getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/api/album/${encodeURIComponent(subsonicId)}/tracks`),
markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`),
streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`,
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,29 @@
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 { usePlayer } from '../player/PlayerContext.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 { load } = usePlayer()
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 +32,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 +60,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 +110,19 @@ export default function ItemPage() {
</p> </p>
)} )}
{item.ripStatus === 'ripped' && item.matchedAlbum && (
<button
type="button"
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
>
Play album
</button>
)}
{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 +133,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 +149,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 +166,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 +241,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

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { usePlayer } from './PlayerContext.js'
import Cover from '../components/Cover.js'
export default function MiniBar() {
const { state, toggle, next, prev, close } = usePlayer()
const [expanded, setExpanded] = useState(false)
if (!state.album) return null
const current = state.tracks[state.index]
if (expanded) {
return (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl rounded-t-2xl border border-neutral-700 bg-neutral-900 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{state.album.title}</p>
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
</button>
<button type="button" onClick={close} aria-label="close player" className="text-neutral-400">
</button>
</div>
</div>
<ol className="mt-3 max-h-64 space-y-1 overflow-y-auto text-sm">
{state.tracks.map((t, i) => (
<li
key={t.id}
className={`flex justify-between rounded-lg px-2 py-1 ${
i === state.index ? 'bg-neutral-800 text-emerald-400' : 'text-neutral-300'
} ${state.failed.includes(i) ? 'line-through opacity-50' : ''}`}
>
<span className="truncate">
{t.track ?? i + 1}. {t.title}
</span>
{t.duration != null && <span className="ml-2 shrink-0 text-neutral-500">{Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}</span>}
</li>
))}
</ol>
</div>
)
}
return (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl px-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-700 bg-neutral-900/95 p-2 shadow-lg backdrop-blur">
<button type="button" onClick={() => setExpanded(true)} aria-label="expand player" className="shrink-0">
<Cover src={null} alt="" className="size-10" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{current?.title}</p>
<p className="truncate text-xs text-neutral-400">{state.album.title}</p>
</div>
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={close} aria-label="close player" className="px-1 text-neutral-500">
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { createContext, useContext, useEffect, useReducer, useCallback, useRef, type ReactNode } from 'react'
import { api } from '../api.js'
import type { Track } from '../types.js'
export interface PlayerAlbum {
id: string
title: string
artist: string
}
export interface PlayerState {
album: PlayerAlbum | null
tracks: Track[]
index: number
playing: boolean
failed: number[]
}
type PlayerAction =
| { type: 'LOAD'; album: PlayerAlbum; tracks: Track[] }
| { type: 'TOGGLE' }
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'TRACK_ERROR' }
| { type: 'CLOSE' }
const INITIAL: PlayerState = { album: null, tracks: [], index: 0, playing: false, failed: [] }
export function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
switch (action.type) {
case 'LOAD':
return { album: action.album, tracks: action.tracks, index: 0, playing: action.tracks.length > 0, failed: [] }
case 'TOGGLE':
return { ...state, playing: !state.playing }
case 'NEXT':
return state.index < state.tracks.length - 1 ? { ...state, index: state.index + 1, playing: true } : { ...state, playing: false }
case 'PREV':
return state.index > 0 ? { ...state, index: state.index - 1, playing: true } : state
case 'TRACK_ERROR':
return {
...state,
failed: state.failed.includes(state.index) ? state.failed : [...state.failed, state.index],
...(state.index < state.tracks.length - 1 ? { index: state.index + 1, playing: true } : { playing: false }),
}
case 'CLOSE':
return INITIAL
}
}
interface PlayerContextValue {
state: PlayerState
load: (album: PlayerAlbum) => Promise<void>
toggle: () => void
next: () => void
prev: () => void
close: () => void
}
const PlayerContext = createContext<PlayerContextValue | null>(null)
export function PlayerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(playerReducer, INITIAL)
const load = useCallback(async (album: PlayerAlbum) => {
const data = await api.getAlbumTracks(album.id)
dispatch({ type: 'LOAD', album: { id: data.id, title: data.title, artist: data.artist }, tracks: data.tracks })
void api.markPlayed(album.id).catch(() => {})
}, [])
const audioRef = useRef<HTMLAudioElement | null>(null)
const current = state.tracks[state.index]
// keep the single audio element in sync with the reducer
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (!current) {
audio.pause()
audio.removeAttribute('src')
return
}
const wanted = api.streamUrl(current.id)
if (!audio.src.endsWith(wanted)) audio.src = wanted
if (state.playing) void audio.play().catch(() => {})
else audio.pause()
}, [current, state.playing])
const onEnded = useCallback(() => dispatch({ type: 'NEXT' }), [])
const onError = useCallback(() => dispatch({ type: 'TRACK_ERROR' }), [])
const toggle = useCallback(() => dispatch({ type: 'TOGGLE' }), [])
const next = useCallback(() => dispatch({ type: 'NEXT' }), [])
const prev = useCallback(() => dispatch({ type: 'PREV' }), [])
const close = useCallback(() => dispatch({ type: 'CLOSE' }), [])
return (
<PlayerContext.Provider value={{ state, load, toggle, next, prev, close }}>
{children}
<audio ref={audioRef} onEnded={onEnded} onError={onError} preload="none" />
</PlayerContext.Provider>
)
}
export function usePlayer(): PlayerContextValue {
const ctx = useContext(PlayerContext)
if (!ctx) throw new Error('usePlayer outside PlayerProvider')
return ctx
}

View File

@@ -68,3 +68,49 @@ export interface DigitalAlbum {
title: string title: string
artist: string artist: string
} }
export interface Track {
id: string
title: string
duration: number | null
track: number | null
}
export interface AlbumTracks {
id: string
title: string
artist: string
tracks: Track[]
}
export interface MatchedAlbum {
id: number
subsonicId: string
lastPlayedAt: 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
}

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

@@ -0,0 +1,43 @@
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('album tracks + played + stream url', async () => {
fetchMock.mockImplementation(() => jsonOk({ id: 'a1', title: 'Motion', artist: 'TCO', tracks: [] }))
await api.getAlbumTracks('a1')
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/tracks', expect.anything())
await api.markPlayed('a1')
expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/played', expect.objectContaining({ method: 'POST' }))
expect(api.streamUrl('s 1')).toBe('/api/stream/s%201')
})
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.getAlbumTracks('a1').catch((e) => e)
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).code).toBe('no_subsonic_config')
})
})

View File

@@ -3,19 +3,31 @@ 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 { PlayerProvider } from '../src/player/PlayerContext.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(),
getAlbumTracks: vi.fn(),
markPlayed: 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,24 +44,37 @@ 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' }
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
beforeEach(() => { beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
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)
vi.mocked(api.setRip).mockReset() vi.mocked(api.setRip).mockReset()
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.getAlbumTracks).mockReset()
vi.mocked(api.markPlayed).mockReset()
vi.mocked(api.lendItem).mockReset()
vi.mocked(api.returnLoan).mockReset()
}) })
function renderItem() { function renderItem() {
return render( return render(
<MemoryRouter initialEntries={['/item/1']}> <MemoryRouter initialEntries={['/item/1']}>
<PlayerProvider>
<Routes> <Routes>
<Route path="/item/:id" element={<ItemPage />} /> <Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} /> <Route path="/library" element={<p>library</p>} />
</Routes> </Routes>
</PlayerProvider>
</MemoryRouter> </MemoryRouter>
) )
} }
@@ -94,6 +119,27 @@ describe('ItemPage', () => {
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77)) await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
}) })
it('re-match refreshes the play button 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('button', { name: /play album/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('button', { name: /play album/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 +188,51 @@ 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 Play for a ripped item with a matched album and loads the player', async () => {
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderItem()
const play = await screen.findByRole('button', { name: /play album/i })
await userEvent.click(play)
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
})
it('hides Play when unmatched or not ripped', async () => {
renderItem()
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
expect(screen.queryByRole('button', { name: /play album/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() }))
)
})
}) })

81
web/test/minibar.test.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js'
import MiniBar from '../src/player/MiniBar.js'
import type { Track } 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, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'
const tracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
function Loader() {
const { load } = usePlayer()
return (
<button type="button" onClick={() => void load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>
load
</button>
)
}
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
})
function renderBar() {
return render(
<PlayerProvider>
<Loader />
<MiniBar />
</PlayerProvider>
)
}
describe('MiniBar', () => {
it('hidden when nothing is loaded, shows controls when playing', async () => {
renderBar()
expect(screen.queryByRole('button', { name: /play or pause/i })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('Motion')).toBeTruthy())
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
})
it('pause/resume works from the bar', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
const pauseBtn = await screen.findByRole('button', { name: /pause/i })
await userEvent.click(pauseBtn)
expect(screen.getByRole('button', { name: 'play' })).toBeTruthy()
})
it('expands to the track list and closes', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
expect(screen.getByText(/Theme de Yoyo/)).toBeTruthy()
expect(screen.getByText(/Overture/)).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /collapse/i }))
expect(screen.queryByText(/Theme de Yoyo/)).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /close player/i }))
await waitFor(() => expect(screen.queryByText('Motion')).toBeNull())
})
it('expanded view has prev/next/pause controls', async () => {
renderBar()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
expect(screen.getByRole('button', { name: /previous track/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /next track/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
})
})

135
web/test/player.test.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer, playerReducer, type PlayerState } from '../src/player/PlayerContext.js'
import type { Track } 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, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
})
const tracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
function Probe() {
const { state, load, toggle, next, prev, close } = usePlayer()
return (
<div>
<div>phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}</div>
<div>track:{state.tracks[state.index]?.title ?? 'none'}</div>
<button type="button" onClick={() => load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>load</button>
<button type="button" onClick={() => toggle()}>toggle</button>
<button type="button" onClick={() => next()}>next</button>
<button type="button" onClick={() => prev()}>prev</button>
<button type="button" onClick={() => close()}>close</button>
</div>
)
}
function renderPlayer() {
return render(
<PlayerProvider>
<Probe />
</PlayerProvider>
)
}
describe('PlayerProvider', () => {
it('loads a queue, stamps played, starts at track 1', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
expect(screen.getByText('track:Overture')).toBeTruthy()
expect(api.getAlbumTracks).toHaveBeenCalledWith('a1')
expect(api.markPlayed).toHaveBeenCalledWith('a1')
})
it('toggle pauses and resumes', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:paused')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:playing')).toBeTruthy()
})
it('next/prev move through the queue and stop at the edges', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() // last track: no advance
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy() // first track: no rewind
})
it('close empties the player', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'close' }))
expect(screen.getByText('phase:empty')).toBeTruthy()
})
it('audio error marks the track failed and skips to the next', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
act(() => {
document.querySelector('audio')!.dispatchEvent(new Event('error'))
})
await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy())
})
})
const twoTracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
describe('playerReducer edges', () => {
const loaded: PlayerState = playerReducer(
{ album: null, tracks: [], index: 0, playing: false, failed: [] },
{ type: 'LOAD', album: { id: 'a1', title: 'Motion', artist: 'TCO' }, tracks: twoTracks }
)
it('LOAD with empty tracks does not play', () => {
const s = playerReducer(loaded, { type: 'LOAD', album: loaded.album!, tracks: [] })
expect(s.playing).toBe(false)
})
it('TRACK_ERROR at last track pauses without advancing, records failure once', () => {
const atLast = playerReducer(loaded, { type: 'NEXT' })
const err = playerReducer(atLast, { type: 'TRACK_ERROR' })
expect(err.index).toBe(1)
expect(err.playing).toBe(false)
expect(err.failed).toEqual([1])
const again = playerReducer(err, { type: 'TRACK_ERROR' })
expect(again.failed).toEqual([1])
})
})

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