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 { registerCollectionRoutes } from './routes/collectionRoutes.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' {
interface FastifyInstance {
@@ -52,6 +56,10 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
await registerLookupRoutes(app)
await registerCollectionRoutes(app)
await registerLibraryRoutes(app)
await registerStreamRoutes(app)
await registerLoanRoutes(app)
await registerStatsRoutes(app)
await registerDataRoutes(app)
// artwork cache (always available)
await app.register(fastifyStatic, {

View File

@@ -5,6 +5,7 @@ import crypto from 'node:crypto'
export interface Config {
dataDir: string
artworkDir: string
backupsDir: string
dbPath: string
port: number
sessionSecret: string
@@ -15,9 +16,12 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
mkdirSync(dataDir, { recursive: true })
const artworkDir = path.join(dataDir, 'artwork-cache')
mkdirSync(artworkDir, { recursive: true })
const backupsDir = path.join(dataDir, 'backups')
mkdirSync(backupsDir, { recursive: true })
return {
dataDir,
artworkDir,
backupsDir,
dbPath: path.join(dataDir, 'record-shop.db'),
port: Number(env.PORT ?? 3000),
sessionSecret: getOrCreateSecret(dataDir),

View File

@@ -10,8 +10,7 @@ export function openDatabase(path: string): DB {
return db
}
export function migrate(db: DB): void {
db.exec(`
const BASE_SCHEMA = `
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
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,
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'
})
}
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 type { DB } from '../db.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 { 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,
}
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
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
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)
)
}
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 }
})
@@ -165,7 +179,14 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
const db = request.server.db
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
if (!row) return reply.code(404).send({ error: 'not_found' })
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) => {

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')
}
/** Raw endpoint URL with auth params — for streaming passthrough. */
url(endpoint: string, params: Record<string, string> = {}): string {
const u = new URL(`${this.base}/rest/${endpoint}`)
const search = { ...this.authParams(), ...params }
for (const [k, v] of Object.entries(search)) u.searchParams.set(k, v)
return u.toString()
}
async getAlbum(albumId: string): Promise<{ id: string; title: string; artist: string; tracks: { id: string; title: string; duration: number | null; track: number | null }[] }> {
const envelope = await this.request('getAlbum', { id: albumId })
const album = envelope.album ?? {}
const songs: any[] = album.song ?? []
const tracks = songs
.map((s) => ({
id: String(s.id),
title: String(s.title ?? ''),
duration: Number.isFinite(Number(s.duration)) ? Number(s.duration) : null,
track: Number.isInteger(Number(s.track)) ? Number(s.track) : null,
}))
.sort((a, b) => (a.track ?? 9999) - (b.track ?? 9999))
return { id: String(album.id ?? albumId), title: album.name ?? album.title ?? '', artist: album.artist ?? '', tracks }
}
async getRecentAlbums(size = 500): Promise<{ id: string; title: string; artist: string; playedAt: string | null }[]> {
const envelope = await this.request('getAlbumList2', { type: 'recent', size: String(size) })
const list: any[] = envelope.albumList2?.album ?? []
return list.map((a) => ({
id: String(a.id),
title: a.name ?? a.title ?? '',
artist: a.artist ?? '',
playedAt: typeof a.played === 'string' ? a.played : typeof a.playedAt === 'string' ? a.playedAt : null,
}))
}
async getAllAlbums(
onProgress?: (albums: SubsonicAlbum[], done: number) => void
): Promise<SubsonicAlbum[]> {

View File

@@ -54,6 +54,18 @@ export class SyncManager {
}
})
apply()
// best-effort: stamp played timestamps reported by the server
try {
const recent = await client.getRecentAlbums(500)
const stamp = this.db.prepare(
'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
)
for (const r of recent) {
if (r.playedAt) stamp.run(r.playedAt, userId, r.id)
}
} catch {
// recency stamping is optional
}
this.states.set(userId, {
status: 'done',
error: null,

View File

@@ -212,4 +212,39 @@ describe('collection routes', () => {
expect(list.json().items).toHaveLength(0)
await app.close()
})
it('detail includes matchedAlbum (null when unmatched)', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const id = added.json().id as number
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.json().matchedAlbum).toBeNull()
await app.inject({
method: '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 {
// artworkDir must be a real writable directory (cached images land there)
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
// backupsDir must be a real writable directory (backup files land there)
const backupsDir = mkdtempSync(path.join(tmpdir(), 'rs-bak-'))
// dataDir must be a real writable directory (release-cache payloads land there)
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
return {
dataDir,
artworkDir,
backupsDir,
dbPath: ':memory:',
port: 0,
sessionSecret: 'test-secret-test-secret-test-secret-1234',

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 { openDatabase, type DB } from '../src/db.js'
import { resolveRipStatus } from '../src/ripstatus.js'
import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js'
describe('resolveRipStatus', () => {
let db: DB
@@ -56,3 +56,42 @@ describe('resolveRipStatus', () => {
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' },
])
})
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 { AuthProvider, useAuth } from './auth'
import Shell from './shell'
import { PlayerProvider } from './player/PlayerContext.js'
import MiniBar from './player/MiniBar.js'
import SetupPage from './pages/SetupPage'
import LoginPage from './pages/LoginPage'
import LibraryPage from './pages/LibraryPage'
@@ -9,6 +11,8 @@ import ItemPage from './pages/ItemPage'
import ScanPage from './pages/ScanPage'
import AddPage from './pages/AddPage'
import SettingsPage from './pages/SettingsPage'
import StatsPage from './pages/StatsPage'
import QueuePage from './pages/QueuePage'
function Gate({ children }: { children: ReactNode }) {
const { status } = useAuth()
@@ -30,7 +34,10 @@ export default function App() {
<Route
element={
<Gate>
<PlayerProvider>
<Shell />
<MiniBar />
</PlayerProvider>
</Gate>
}
>
@@ -39,6 +46,8 @@ export default function App() {
<Route path="/scan" element={<ScanPage />} />
<Route path="/add" element={<AddPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/queue" element={<QueuePage />} />
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>

View File

@@ -1,10 +1,16 @@
import type {
AlbumTracks,
BackupFile,
Candidate,
CollectionResponse,
DigitalAlbum,
Item,
ItemDetail,
Loan,
LoansResponse,
ReleasePreview,
SettingsView,
Stats,
SyncState,
User,
} from './types'
@@ -73,17 +79,18 @@ export const api = {
),
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()
if (params.format) usp.set('format', params.format)
if (params.ripped) usp.set('ripped', params.ripped)
if (params.q) usp.set('q', params.q)
if (params.onLoan) usp.set('onLoan', params.onLoan)
const qs = usp.toString()
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
},
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
post<Item>('/api/collection', body),
getItem: (id: number) => request<Item>(`/api/collection/${id}`),
getItem: (id: number) => request<ItemDetail>(`/api/collection/${id}`),
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
setRip: (id: number, ripped: boolean | null) =>
request<Item>(`/api/collection/${id}/rip`, {
@@ -97,4 +104,18 @@ export const api = {
syncStatus: () => request<SyncState>('/api/library/sync'),
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
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 { api } from '../api.js'
import type { DigitalAlbum, Item } from '../types.js'
import { api, ApiError } from '../api.js'
import { usePlayer } from '../player/PlayerContext.js'
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.js'
function timeAgo(iso: string): string {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (secs < 60) return 'just now'
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago`
}
export default function ItemPage() {
const { id } = useParams()
const navigate = useNavigate()
const [item, setItem] = useState<Item | null>(null)
const { load } = usePlayer()
const [item, setItem] = useState<ItemDetail | null>(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
const [albumQuery, setAlbumQuery] = useState('')
@@ -16,13 +32,19 @@ export default function ItemPage() {
const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState<string | null>(null)
const refetch = useCallback(() => {
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
}, [id])
useEffect(() => {
setMutationError(null)
void api
.getItem(Number(id))
.then(setItem)
.catch(() => setError(true))
}, [id])
refetch()
}, [refetch])
/** Rip/match mutations return a plain Item — keep the detail-only fields. */
function applyUpdated(updated: Item) {
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
}
function searchAlbums() {
void api
@@ -38,10 +60,7 @@ export default function ItemPage() {
if (!item) return
void api
.setMatch(item.id, albumId)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.then(() => refetch())
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
@@ -91,6 +110,19 @@ export default function ItemPage() {
</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>}
<div className="flex flex-wrap gap-2">
@@ -101,7 +133,7 @@ export default function ItemPage() {
void api
.setRip(item.id, false)
.then((updated) => {
setItem(updated)
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -117,7 +149,7 @@ export default function ItemPage() {
void api
.setRip(item.id, true)
.then((updated) => {
setItem(updated)
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -134,7 +166,7 @@ export default function ItemPage() {
void api
.setRip(item.id, null)
.then((updated) => {
setItem(updated)
applyUpdated(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
@@ -209,6 +241,63 @@ export default function ItemPage() {
)}
</section>
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
{item.loan ? (
<div className="flex items-center justify-between">
<p className="text-sm text-neutral-300">
Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
</p>
<button
type="button"
onClick={() =>
void api
.returnLoan(item.loan!.id)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark returned
</button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
void api
.lendItem(item.id, borrower)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}}
className="flex gap-2"
>
<input
name="borrower"
aria-label="Borrower"
placeholder="Lend to…"
required
className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
/>
<button type="submit" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300">
Lend
</button>
</form>
)}
</section>
{item.tracklist.length > 0 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>

View File

@@ -10,6 +10,7 @@ const RIP = ['All', 'Ripped', 'Not ripped'] as const
export default function LibraryPage() {
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
const [onLoan, setOnLoan] = useState(false)
const [q, setQ] = useState('')
const [data, setData] = useState<CollectionResponse | null>(null)
const [error, setError] = useState(false)
@@ -20,6 +21,7 @@ export default function LibraryPage() {
const params = {
...(format !== 'All' ? { format } : {}),
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
...(onLoan ? { onLoan: 'true' } : {}),
...(q.trim() ? { q: q.trim() } : {}),
}
void api
@@ -33,7 +35,7 @@ export default function LibraryPage() {
.catch(() => {
if (seq.current === mine) setError(true)
})
}, [format, ripped, q])
}, [format, ripped, onLoan, q])
const artists = useMemo(() => {
const set = new Set<string>()
@@ -43,6 +45,15 @@ export default function LibraryPage() {
return (
<div className="space-y-4">
<div className="flex gap-3 text-sm">
<Link to="/stats" className="text-emerald-400">
Stats
</Link>
<Link to="/queue" className="text-emerald-400">
Queue
</Link>
</div>
<input
type="search"
placeholder="Search title or artist"
@@ -76,6 +87,15 @@ export default function LibraryPage() {
{r}
</button>
))}
<button
type="button"
onClick={() => setOnLoan((v) => !v)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
onLoan ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
On loan
</button>
</div>
{error && <p className="text-sm text-red-400">Could not load your collection.</p>}

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 { api } from '../api.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 }) {
return (
@@ -29,6 +29,7 @@ export default function SettingsPage() {
const [sync, setSync] = useState<SyncState | null>(null)
const [users, setUsers] = useState<User[] | null>(null)
const [backups, setBackups] = useState<BackupFile[] | null>(null)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
@@ -81,6 +82,7 @@ export default function SettingsPage() {
.listUsers()
.then((res) => setUsers(res.users))
.catch(() => {})
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
}
}, [user])
@@ -237,6 +239,46 @@ export default function SettingsPage() {
</button>
</Section>
<Section title="Data">
<div className="flex flex-wrap gap-2">
<a
href={api.exportUrl()}
download
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Export JSON
</a>
{user?.isAdmin && (
<button
type="button"
onClick={() =>
void api
.triggerBackup()
.then(() => api.getBackups())
.then((res) => setBackups(res.backups))
.then(() => flash('ok', 'Backup created'))
.catch(() => flash('error', 'Backup failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Back up now
</button>
)}
</div>
{user?.isAdmin && backups && backups.length > 0 && (
<ul className="space-y-1 text-xs text-neutral-400">
{backups.map((b) => (
<li key={b.file} className="flex justify-between gap-2">
<span className="truncate">{b.file}</span>
<span className="shrink-0">
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</Section>
{user?.isAdmin && (
<Section title="Users">
<ul className="space-y-1.5 text-sm">

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
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 { MemoryRouter, Route, Routes } from 'react-router-dom'
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) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return {
...actual,
api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() },
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,
discogsReleaseId: 1001,
title: 'Motion',
@@ -32,24 +44,37 @@ const item: Item = {
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
matchedAlbum: null,
loan: null,
}
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
vi.mocked(api.getItem).mockReset()
vi.mocked(api.getItem).mockResolvedValue(item as never)
vi.mocked(api.setRip).mockReset()
vi.mocked(api.setMatch).mockReset()
vi.mocked(api.deleteItem).mockReset()
vi.mocked(api.searchAlbums).mockReset()
vi.mocked(api.getAlbumTracks).mockReset()
vi.mocked(api.markPlayed).mockReset()
vi.mocked(api.lendItem).mockReset()
vi.mocked(api.returnLoan).mockReset()
})
function renderItem() {
return render(
<MemoryRouter initialEntries={['/item/1']}>
<PlayerProvider>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</PlayerProvider>
</MemoryRouter>
)
}
@@ -94,6 +119,27 @@ describe('ItemPage', () => {
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 () => {
vi.mocked(api.setMatch).mockResolvedValue(item as never)
renderItem()
@@ -142,4 +188,51 @@ describe('ItemPage', () => {
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
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())
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(),
deleteUser: vi.fn(),
logout: vi.fn(),
triggerBackup: vi.fn(),
getBackups: vi.fn(),
},
}
})
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
}
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(api.getSettings).mockResolvedValue(emptyView as never)
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
stubAuthFetch(admin)
})
@@ -179,4 +182,30 @@ describe('SettingsPage', () => {
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
})
it('data section: export link, backup now, backups list', async () => {
vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never)
vi.mocked(api.getBackups).mockResolvedValue({
backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }],
} as never)
renderSettings()
expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href')
await userEvent.click(screen.getByRole('button', { name: /back up now/i }))
await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled())
await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy())
})
it('backup failure surfaces in the flash banner', async () => {
vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed'))
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
})
it('non-admin sees the Export link but not backups', async () => {
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
renderSettings()
expect(await screen.findByRole('link', { name: /export json/i })).toBeTruthy()
expect(screen.queryByRole('button', { name: /back up now/i })).toBeNull()
})
})

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