Compare commits
19 Commits
3fa0b02560
...
3edf7574f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 3edf7574f9 | |||
| 9b2bc22d59 | |||
| b33399c865 | |||
| fc5797d929 | |||
| 47858b1788 | |||
| ece18d125b | |||
| b2711b2b79 | |||
| c8b596bb02 | |||
| ee19bc773c | |||
| 3ce55ba09e | |||
| 3176807268 | |||
| 53b4bc39f5 | |||
| f8f240f00a | |||
| d63bc4bc12 | |||
| ebac55b7a5 | |||
| 9d1291b58f | |||
| 3bdbd8315c | |||
| bc3db9e4f7 | |||
| 024cc6d953 |
2582
docs/superpowers/plans/2026-09-03-wave1.md
Normal file
2582
docs/superpowers/plans/2026-09-03-wave1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -14,6 +14,10 @@ import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
|||||||
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
||||||
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
||||||
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
||||||
|
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
||||||
|
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
||||||
|
import { registerStatsRoutes } from './routes/statsRoutes.js'
|
||||||
|
import { registerDataRoutes } from './routes/dataRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -52,6 +56,10 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerLookupRoutes(app)
|
await registerLookupRoutes(app)
|
||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
await registerLibraryRoutes(app)
|
await registerLibraryRoutes(app)
|
||||||
|
await registerStreamRoutes(app)
|
||||||
|
await registerLoanRoutes(app)
|
||||||
|
await registerStatsRoutes(app)
|
||||||
|
await registerDataRoutes(app)
|
||||||
|
|
||||||
// artwork cache (always available)
|
// artwork cache (always available)
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import crypto from 'node:crypto'
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
dataDir: string
|
dataDir: string
|
||||||
artworkDir: string
|
artworkDir: string
|
||||||
|
backupsDir: string
|
||||||
dbPath: string
|
dbPath: string
|
||||||
port: number
|
port: number
|
||||||
sessionSecret: string
|
sessionSecret: string
|
||||||
@@ -15,9 +16,12 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
|||||||
mkdirSync(dataDir, { recursive: true })
|
mkdirSync(dataDir, { recursive: true })
|
||||||
const artworkDir = path.join(dataDir, 'artwork-cache')
|
const artworkDir = path.join(dataDir, 'artwork-cache')
|
||||||
mkdirSync(artworkDir, { recursive: true })
|
mkdirSync(artworkDir, { recursive: true })
|
||||||
|
const backupsDir = path.join(dataDir, 'backups')
|
||||||
|
mkdirSync(backupsDir, { recursive: true })
|
||||||
return {
|
return {
|
||||||
dataDir,
|
dataDir,
|
||||||
artworkDir,
|
artworkDir,
|
||||||
|
backupsDir,
|
||||||
dbPath: path.join(dataDir, 'record-shop.db'),
|
dbPath: path.join(dataDir, 'record-shop.db'),
|
||||||
port: Number(env.PORT ?? 3000),
|
port: Number(env.PORT ?? 3000),
|
||||||
sessionSecret: getOrCreateSecret(dataDir),
|
sessionSecret: getOrCreateSecret(dataDir),
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export function openDatabase(path: string): DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrate(db: DB): void {
|
const BASE_SCHEMA = `
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS app_meta (
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -76,5 +75,48 @@ export function migrate(db: DB): void {
|
|||||||
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
|
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (user_id, item_id)
|
PRIMARY KEY (user_id, item_id)
|
||||||
);
|
);
|
||||||
`)
|
`
|
||||||
|
|
||||||
|
function getSchemaVersion(db: DB): number {
|
||||||
|
const row = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
return row ? Number(row.value) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSchemaVersion(db: DB, version: number): void {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO app_meta (key, value) VALUES ('schema_version', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||||
|
).run(String(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upgrade steps for databases created before schema versioning existed. */
|
||||||
|
export function migrateUpgrades(db: DB): void {
|
||||||
|
const version = getSchemaVersion(db)
|
||||||
|
if (version < 2) {
|
||||||
|
const hasColumn = (db.prepare('PRAGMA table_info(digital_albums)').all() as { name: string }[]).some(
|
||||||
|
(c) => c.name === 'last_played_at'
|
||||||
|
)
|
||||||
|
const apply = db.transaction(() => {
|
||||||
|
if (!hasColumn) {
|
||||||
|
db.exec('ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT')
|
||||||
|
}
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS loans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
|
||||||
|
borrower TEXT NOT NULL,
|
||||||
|
lent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
returned_at TEXT
|
||||||
|
);`)
|
||||||
|
setSchemaVersion(db, 2)
|
||||||
|
})
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrate(db: DB): void {
|
||||||
|
db.exec(BASE_SCHEMA)
|
||||||
|
migrateUpgrades(db)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,3 +62,37 @@ export function resolveRipStatusBatch(
|
|||||||
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
|
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MatchedAlbum {
|
||||||
|
id: number
|
||||||
|
subsonicId: string
|
||||||
|
lastPlayedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Album behind an item's rip match — link wins, else confident fuzzy match. Independent of rip_override. */
|
||||||
|
export function findMatchedAlbum(db: DB, userId: number, itemId: number): MatchedAlbum | null {
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT id, title, artist FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.get(itemId, userId) as { id: number; title: string; artist: string } | undefined
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
const link = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT da.id, da.subsonic_id, da.last_played_at FROM match_links ml
|
||||||
|
JOIN digital_albums da ON da.id = ml.album_id WHERE ml.item_id = ?`
|
||||||
|
)
|
||||||
|
.get(itemId) as { id: number; subsonic_id: string; last_played_at: string | null } | undefined
|
||||||
|
if (link) {
|
||||||
|
return { id: link.id, subsonicId: link.subsonic_id, lastPlayedAt: link.last_played_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, subsonic_id, last_played_at, title, artist FROM digital_albums WHERE user_id = ?`
|
||||||
|
)
|
||||||
|
.all(userId) as { id: number; subsonic_id: string; last_played_at: string | null; title: string; artist: string }[]
|
||||||
|
const match = album.find((a) => isConfidentMatch(item, a))
|
||||||
|
return match
|
||||||
|
? { id: match.id, subsonicId: match.subsonic_id, lastPlayedAt: match.last_played_at }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import type { DB } from '../db.js'
|
import type { DB } from '../db.js'
|
||||||
import { cacheArtwork } from '../artwork.js'
|
import { cacheArtwork } from '../artwork.js'
|
||||||
import { resolveRipStatus, resolveRipStatusBatch, type RipStatus } from '../ripstatus.js'
|
import { resolveRipStatus, resolveRipStatusBatch, findMatchedAlbum, type RipStatus } from '../ripstatus.js'
|
||||||
import { requireAuth } from './authRoutes.js'
|
import { requireAuth } from './authRoutes.js'
|
||||||
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
||||||
|
|
||||||
@@ -146,7 +146,12 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string }
|
const { format, ripped, q, onLoan } = request.query as {
|
||||||
|
format?: string
|
||||||
|
ripped?: string
|
||||||
|
q?: string
|
||||||
|
onLoan?: string
|
||||||
|
}
|
||||||
let filtered = items
|
let filtered = items
|
||||||
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
||||||
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
||||||
@@ -158,6 +163,15 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onLoan === 'true' || onLoan === 'false') {
|
||||||
|
const want = onLoan === 'true'
|
||||||
|
filtered = filtered.filter((i) => {
|
||||||
|
const has = !!db
|
||||||
|
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||||
|
.get(i.id)
|
||||||
|
return has === want
|
||||||
|
})
|
||||||
|
}
|
||||||
return { items: filtered, counts }
|
return { items: filtered, counts }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,7 +179,14 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
|||||||
const db = request.server.db
|
const db = request.server.db
|
||||||
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' })
|
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||||||
return rowToItem(db, row)
|
const loan = db
|
||||||
|
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||||
|
.get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
|
||||||
|
return {
|
||||||
|
...rowToItem(db, row),
|
||||||
|
matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id),
|
||||||
|
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
|||||||
71
server/src/routes/dataRoutes.ts
Normal file
71
server/src/routes/dataRoutes.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { FastifyInstance } from 'fastify'
|
||||||
|
import { existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { requireAuth, requireAdmin } from './authRoutes.js'
|
||||||
|
|
||||||
|
function matchLinksFor(db: any, userId: number) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ml.item_id AS itemId, ml.album_id AS albumId FROM match_links ml WHERE ml.user_id = ?`
|
||||||
|
)
|
||||||
|
.all(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBackups(dir: string): { file: string; sizeBytes: number; createdAt: string }[] {
|
||||||
|
try {
|
||||||
|
return readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith('.db'))
|
||||||
|
.map((file) => {
|
||||||
|
const full = path.join(dir, file)
|
||||||
|
const st = statSync(full)
|
||||||
|
return { file, sizeBytes: st.size, createdAt: st.mtime.toISOString() }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDataRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/export', { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added')
|
||||||
|
.all(userId)
|
||||||
|
const loans = db.prepare('SELECT * FROM loans WHERE user_id = ?').all(userId)
|
||||||
|
return {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
items,
|
||||||
|
loans,
|
||||||
|
matchLinks: matchLinksFor(db, userId),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/backup', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||||
|
const config = request.server.config
|
||||||
|
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19)
|
||||||
|
// second-granularity stamp: make the destination unique for rapid successive backups
|
||||||
|
let dest = path.join(config.backupsDir, `record-shop-${stamp}.db`)
|
||||||
|
let suffix = 1
|
||||||
|
while (existsSync(dest)) {
|
||||||
|
dest = path.join(config.backupsDir, `record-shop-${stamp}-${suffix}.db`)
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
await request.server.db.backup(dest)
|
||||||
|
// prune to newest 7
|
||||||
|
const backups = listBackups(config.backupsDir)
|
||||||
|
for (const old of backups.slice(7)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(path.join(config.backupsDir, old.file))
|
||||||
|
} catch {
|
||||||
|
// best-effort prune
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { file: path.basename(dest) }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/backups', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
|
||||||
|
return { backups: listBackups(request.server.config.backupsDir) }
|
||||||
|
})
|
||||||
|
}
|
||||||
64
server/src/routes/loanRoutes.ts
Normal file
64
server/src/routes/loanRoutes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { FastifyInstance } from 'fastify'
|
||||||
|
import { requireAuth } from './authRoutes.js'
|
||||||
|
|
||||||
|
interface LoanRow {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
item_id: number
|
||||||
|
borrower: string
|
||||||
|
lent_at: string
|
||||||
|
returned_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoan(l: LoanRow) {
|
||||||
|
return { id: l.id, itemId: l.item_id, borrower: l.borrower, lentAt: l.lent_at, returnedAt: l.returned_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerLoanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post('/api/collection/:id/loan', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT id FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.get(id, userId)
|
||||||
|
if (!item) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
const { borrower } = (request.body ?? {}) as { borrower?: string }
|
||||||
|
if (typeof borrower !== 'string' || borrower.trim() === '') {
|
||||||
|
return reply.code(400).send({ error: 'invalid_input', detail: 'borrower is required' })
|
||||||
|
}
|
||||||
|
const active = db
|
||||||
|
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||||
|
.get(id)
|
||||||
|
if (active) return reply.code(409).send({ error: 'already_on_loan' })
|
||||||
|
const info = db
|
||||||
|
.prepare('INSERT INTO loans (user_id, item_id, borrower) VALUES (?, ?, ?)')
|
||||||
|
.run(userId, id, borrower.trim())
|
||||||
|
const loan = db.prepare('SELECT * FROM loans WHERE id = ?').get(info.lastInsertRowid) as LoanRow
|
||||||
|
return reply.code(200).send(toLoan(loan))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/loans', { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const active = db
|
||||||
|
.prepare('SELECT * FROM loans WHERE user_id = ? AND returned_at IS NULL ORDER BY lent_at DESC')
|
||||||
|
.all(userId) as LoanRow[]
|
||||||
|
const history = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM loans WHERE user_id = ? AND returned_at IS NOT NULL ORDER BY returned_at DESC LIMIT 50'
|
||||||
|
)
|
||||||
|
.all(userId) as LoanRow[]
|
||||||
|
return { active: active.map(toLoan), history: history.map(toLoan) }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/loans/:id/return', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
const info = db
|
||||||
|
.prepare("UPDATE loans SET returned_at = datetime('now') WHERE id = ? AND user_id = ? AND returned_at IS NULL")
|
||||||
|
.run(id, request.user!.id)
|
||||||
|
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
66
server/src/routes/statsRoutes.ts
Normal file
66
server/src/routes/statsRoutes.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { FastifyInstance } from 'fastify'
|
||||||
|
import { requireAuth } from './authRoutes.js'
|
||||||
|
import { resolveRipStatusBatch } from '../ripstatus.js'
|
||||||
|
|
||||||
|
function countBy(values: string[]): { name: string; count: number }[] {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const v of values) {
|
||||||
|
if (!v) continue
|
||||||
|
map.set(v, (map.get(v) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerStatsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/stats', { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const items = db
|
||||||
|
.prepare('SELECT id, title, artist, year, formats, genres, date_added, rip_override FROM collection_items WHERE user_id = ?')
|
||||||
|
.all(userId) as {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
year: number | null
|
||||||
|
formats: string
|
||||||
|
genres: string
|
||||||
|
date_added: string
|
||||||
|
rip_override: number | null
|
||||||
|
}[]
|
||||||
|
|
||||||
|
const statuses = resolveRipStatusBatch(db, userId, items)
|
||||||
|
let ripped = 0
|
||||||
|
const formats: string[] = []
|
||||||
|
const genres: string[] = []
|
||||||
|
const months = new Map<string, number>()
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
if (statuses[i] === 'ripped') ripped++
|
||||||
|
for (const f of JSON.parse(item.formats) as string[]) formats.push(f)
|
||||||
|
for (const g of JSON.parse(item.genres) as string[]) genres.push(g)
|
||||||
|
const month = (item.date_added ?? '').slice(0, 7)
|
||||||
|
if (/^\d{4}-\d{2}$/.test(month)) months.set(month, (months.get(month) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoan = (
|
||||||
|
db.prepare('SELECT COUNT(*) AS n FROM loans WHERE user_id = ? AND returned_at IS NULL').get(userId) as { n: number }
|
||||||
|
).n
|
||||||
|
|
||||||
|
const addedByMonth: { month: string; count: number }[] = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
addedByMonth.push({ month: key, count: months.get(key) ?? 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totals: { items: items.length, ripped, notRipped: items.length - ripped, onLoan },
|
||||||
|
ripRatio: items.length === 0 ? 0 : ripped / items.length,
|
||||||
|
formats: countBy(formats),
|
||||||
|
decades: countBy(items.map((i) => (i.year ? `${Math.floor(i.year / 10) * 10}s` : ''))),
|
||||||
|
topGenres: countBy(genres).slice(0, 10),
|
||||||
|
topArtists: countBy(items.map((i) => i.artist)).slice(0, 10),
|
||||||
|
addedByMonth,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
65
server/src/routes/streamRoutes.ts
Normal file
65
server/src/routes/streamRoutes.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -75,6 +75,40 @@ export class SubsonicClient {
|
|||||||
await this.request('ping')
|
await this.request('ping')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw endpoint URL with auth params — for streaming passthrough. */
|
||||||
|
url(endpoint: string, params: Record<string, string> = {}): string {
|
||||||
|
const u = new URL(`${this.base}/rest/${endpoint}`)
|
||||||
|
const search = { ...this.authParams(), ...params }
|
||||||
|
for (const [k, v] of Object.entries(search)) u.searchParams.set(k, v)
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbum(albumId: string): Promise<{ id: string; title: string; artist: string; tracks: { id: string; title: string; duration: number | null; track: number | null }[] }> {
|
||||||
|
const envelope = await this.request('getAlbum', { id: albumId })
|
||||||
|
const album = envelope.album ?? {}
|
||||||
|
const songs: any[] = album.song ?? []
|
||||||
|
const tracks = songs
|
||||||
|
.map((s) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
title: String(s.title ?? ''),
|
||||||
|
duration: Number.isFinite(Number(s.duration)) ? Number(s.duration) : null,
|
||||||
|
track: Number.isInteger(Number(s.track)) ? Number(s.track) : null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.track ?? 9999) - (b.track ?? 9999))
|
||||||
|
return { id: String(album.id ?? albumId), title: album.name ?? album.title ?? '', artist: album.artist ?? '', tracks }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentAlbums(size = 500): Promise<{ id: string; title: string; artist: string; playedAt: string | null }[]> {
|
||||||
|
const envelope = await this.request('getAlbumList2', { type: 'recent', size: String(size) })
|
||||||
|
const list: any[] = envelope.albumList2?.album ?? []
|
||||||
|
return list.map((a) => ({
|
||||||
|
id: String(a.id),
|
||||||
|
title: a.name ?? a.title ?? '',
|
||||||
|
artist: a.artist ?? '',
|
||||||
|
playedAt: typeof a.played === 'string' ? a.played : typeof a.playedAt === 'string' ? a.playedAt : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async getAllAlbums(
|
async getAllAlbums(
|
||||||
onProgress?: (albums: SubsonicAlbum[], done: number) => void
|
onProgress?: (albums: SubsonicAlbum[], done: number) => void
|
||||||
): Promise<SubsonicAlbum[]> {
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ export class SyncManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
apply()
|
apply()
|
||||||
|
// best-effort: stamp played timestamps reported by the server
|
||||||
|
try {
|
||||||
|
const recent = await client.getRecentAlbums(500)
|
||||||
|
const stamp = this.db.prepare(
|
||||||
|
'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
|
||||||
|
)
|
||||||
|
for (const r of recent) {
|
||||||
|
if (r.playedAt) stamp.run(r.playedAt, userId, r.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// recency stamping is optional
|
||||||
|
}
|
||||||
this.states.set(userId, {
|
this.states.set(userId, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -212,4 +212,39 @@ describe('collection routes', () => {
|
|||||||
expect(list.json().items).toHaveLength(0)
|
expect(list.json().items).toHaveLength(0)
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('detail includes matchedAlbum (null when unmatched)', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
const id = added.json().id as number
|
||||||
|
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(detail.json().matchedAlbum).toBeNull()
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/library/albums/test-seed',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
|
||||||
|
})
|
||||||
|
const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// KNOWN-RED handoff: the loan route arrives in Task 5 — this test turns
|
||||||
|
// green then. Everything else in this file must pass now.
|
||||||
|
it('list supports onLoan=true/false filter', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
const id = added.json().id as number
|
||||||
|
await app.inject({ method: 'POST', url: `/api/collection/${id}/loan`, ...auth(cookie), payload: { borrower: 'Bob' } })
|
||||||
|
|
||||||
|
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
|
||||||
|
expect(all.json().items).toHaveLength(1)
|
||||||
|
const onLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=true', ...auth(cookie) })
|
||||||
|
expect(onLoan.json().items).toHaveLength(1)
|
||||||
|
const notOnLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=false', ...auth(cookie) })
|
||||||
|
expect(notOnLoan.json().items).toHaveLength(0)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
64
server/test/data.test.ts
Normal file
64
server/test/data.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js'
|
||||||
|
|
||||||
|
async function appWithData() {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/library/albums/test-seed',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] },
|
||||||
|
})
|
||||||
|
return { app, cookie }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
it('returns per-user data without secrets', async () => {
|
||||||
|
const { app, cookie } = await appWithData()
|
||||||
|
await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 'secret-token' } })
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/export', ...auth(cookie) })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = res.json()
|
||||||
|
expect(body.exportedAt).toBeTruthy()
|
||||||
|
expect(body.items).toEqual([])
|
||||||
|
expect(body.loans).toEqual([])
|
||||||
|
expect(body.matchLinks).toEqual([])
|
||||||
|
expect(JSON.stringify(body)).not.toContain('secret-token')
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('backups', () => {
|
||||||
|
it('non-admin cannot trigger backups', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
const bob = await loginAs(app, cookie, 'bob', 'bobpass123')
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(bob) })
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin creates a backup file and lists it', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json().file).toMatch(/record-shop-.*\.db$/)
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
|
||||||
|
expect(list.json().backups).toHaveLength(1)
|
||||||
|
expect(list.json().backups[0].file).toMatch(/\.db$/)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prunes to the newest 7 backups', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
|
||||||
|
}
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
|
||||||
|
expect(list.json().backups).toHaveLength(7)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,11 +9,14 @@ import type { FastifyInstance } from 'fastify'
|
|||||||
export function testConfig(): Config {
|
export function testConfig(): Config {
|
||||||
// artworkDir must be a real writable directory (cached images land there)
|
// artworkDir must be a real writable directory (cached images land there)
|
||||||
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
|
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
|
||||||
|
// backupsDir must be a real writable directory (backup files land there)
|
||||||
|
const backupsDir = mkdtempSync(path.join(tmpdir(), 'rs-bak-'))
|
||||||
// dataDir must be a real writable directory (release-cache payloads land there)
|
// dataDir must be a real writable directory (release-cache payloads land there)
|
||||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
|
||||||
return {
|
return {
|
||||||
dataDir,
|
dataDir,
|
||||||
artworkDir,
|
artworkDir,
|
||||||
|
backupsDir,
|
||||||
dbPath: ':memory:',
|
dbPath: ':memory:',
|
||||||
port: 0,
|
port: 0,
|
||||||
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
||||||
|
|||||||
84
server/test/lastplayed.test.ts
Normal file
84
server/test/lastplayed.test.ts
Normal 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
94
server/test/loans.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildTestApp, setupAdmin, auth } from './helpers.js'
|
||||||
|
import { discogsReleaseFixture } from './fixtures.js'
|
||||||
|
|
||||||
|
const discogsStub = (async (input: any) =>
|
||||||
|
String(input).includes('/releases/1001')
|
||||||
|
? new Response(JSON.stringify(discogsReleaseFixture), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
: new Response('nope', { status: 404 })) as typeof fetch
|
||||||
|
|
||||||
|
async function appWithItem() {
|
||||||
|
const app = await buildTestApp(discogsStub)
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 't' } })
|
||||||
|
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
return { app, cookie, itemId: added.json().id as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loans', () => {
|
||||||
|
it('lend, list active, return', async () => {
|
||||||
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
|
|
||||||
|
const lend = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Bob' },
|
||||||
|
})
|
||||||
|
expect(lend.statusCode).toBe(200)
|
||||||
|
const loan = lend.json()
|
||||||
|
expect(loan).toMatchObject({ itemId, borrower: 'Bob', returnedAt: null })
|
||||||
|
|
||||||
|
const dup = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Eve' },
|
||||||
|
})
|
||||||
|
expect(dup.statusCode).toBe(409)
|
||||||
|
expect(dup.json()).toEqual({ error: 'already_on_loan' })
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
|
||||||
|
expect(list.json().active).toHaveLength(1)
|
||||||
|
expect(list.json().history).toHaveLength(0)
|
||||||
|
|
||||||
|
const ret = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
|
||||||
|
expect(ret.statusCode).toBe(200)
|
||||||
|
const after = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
|
||||||
|
expect(after.json().active).toHaveLength(0)
|
||||||
|
expect(after.json().history).toHaveLength(1)
|
||||||
|
|
||||||
|
const again = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
|
||||||
|
expect(again.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detail route reports the active loan, null before lending', async () => {
|
||||||
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
|
const before = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
|
||||||
|
expect(before.json().loan).toBeNull()
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Bob' },
|
||||||
|
})
|
||||||
|
const after = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
|
||||||
|
expect(after.json().loan).toMatchObject({ borrower: 'Bob' })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates borrower and ownership', async () => {
|
||||||
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
|
const empty = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: ' ' },
|
||||||
|
})
|
||||||
|
expect(empty.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/9999/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Bob' },
|
||||||
|
})
|
||||||
|
expect(missing.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
62
server/test/migrate.test.ts
Normal file
62
server/test/migrate.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { openDatabase, migrateUpgrades } from '../src/db.js'
|
||||||
|
|
||||||
|
/** Builds a pre-v2 database (plan-1 schema, no version row, no new columns). */
|
||||||
|
function legacyDb(): Database.Database {
|
||||||
|
const db = new Database(':memory:')
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.pragma('foreign_keys = ON')
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')));
|
||||||
|
CREATE TABLE sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE settings (user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discogs_token TEXT, subsonic_url TEXT, subsonic_username TEXT, subsonic_password TEXT);
|
||||||
|
CREATE TABLE collection_items (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, discogs_release_id INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, year INTEGER, formats TEXT NOT NULL DEFAULT '[]', genres TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', tracklist TEXT NOT NULL DEFAULT '[]', catno TEXT, country TEXT, cover_url TEXT, local_artwork_path TEXT, barcodes TEXT NOT NULL DEFAULT '[]', rip_override INTEGER, date_added TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, discogs_release_id));
|
||||||
|
CREATE TABLE digital_albums (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subsonic_id TEXT NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, UNIQUE (user_id, subsonic_id));
|
||||||
|
CREATE TABLE match_links (user_id INTEGER NOT NULL, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id));
|
||||||
|
INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1);
|
||||||
|
INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (1, 1, 'a1', 'Motion', 'The Cinematic Orchestra');
|
||||||
|
`)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function columns(db: Database.Database, table: string): string[] {
|
||||||
|
return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schema migration v2', () => {
|
||||||
|
it('migrates a legacy database: loans table, last_played_at, version row', () => {
|
||||||
|
const db = legacyDb()
|
||||||
|
migrateUpgrades(db)
|
||||||
|
expect(columns(db, 'digital_albums')).toContain('last_played_at')
|
||||||
|
expect(columns(db, 'loans')).toEqual([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'item_id',
|
||||||
|
'borrower',
|
||||||
|
'lent_at',
|
||||||
|
'returned_at',
|
||||||
|
])
|
||||||
|
const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
|
||||||
|
expect(version).toEqual({ value: '2' })
|
||||||
|
// existing data survives
|
||||||
|
expect(db.prepare('SELECT title FROM digital_albums').get()).toEqual({ title: 'Motion' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openDatabase creates a fresh v2 database directly', () => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
expect(columns(db, 'digital_albums')).toContain('last_played_at')
|
||||||
|
expect(columns(db, 'loans')).toContain('borrower')
|
||||||
|
const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
|
||||||
|
expect(version).toEqual({ value: '2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrations are idempotent', () => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
expect(() => migrateUpgrades(db)).not.toThrow()
|
||||||
|
expect(
|
||||||
|
(db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as { value: string }).value
|
||||||
|
).toBe('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { openDatabase, type DB } from '../src/db.js'
|
import { openDatabase, type DB } from '../src/db.js'
|
||||||
import { resolveRipStatus } from '../src/ripstatus.js'
|
import { resolveRipStatus, findMatchedAlbum } from '../src/ripstatus.js'
|
||||||
|
|
||||||
describe('resolveRipStatus', () => {
|
describe('resolveRipStatus', () => {
|
||||||
let db: DB
|
let db: DB
|
||||||
@@ -56,3 +56,42 @@ describe('resolveRipStatus', () => {
|
|||||||
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
|
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('findMatchedAlbum', () => {
|
||||||
|
let db: DB
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openDatabase(':memory:')
|
||||||
|
db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run()
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')"
|
||||||
|
).run()
|
||||||
|
})
|
||||||
|
|
||||||
|
function addAlbum(id: number, title: string, artist: string) {
|
||||||
|
db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run(
|
||||||
|
id,
|
||||||
|
`sub-${id}`,
|
||||||
|
title,
|
||||||
|
artist
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the match-linked album', () => {
|
||||||
|
addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
|
||||||
|
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
|
||||||
|
const m = findMatchedAlbum(db, 1, 10)
|
||||||
|
expect(m).toMatchObject({ subsonicId: 'sub-1', lastPlayedAt: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the confident fuzzy match', () => {
|
||||||
|
addAlbum(2, 'Motion!', 'Cinematic Orchestra')
|
||||||
|
const m = findMatchedAlbum(db, 1, 10)
|
||||||
|
expect(m).toMatchObject({ subsonicId: 'sub-2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when nothing matches or the item is missing', () => {
|
||||||
|
expect(findMatchedAlbum(db, 1, 10)).toBeNull()
|
||||||
|
expect(findMatchedAlbum(db, 1, 9999)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
47
server/test/stats.test.ts
Normal file
47
server/test/stats.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openDatabase } from '../src/db.js'
|
||||||
|
import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js'
|
||||||
|
|
||||||
|
function seedItems(db: ReturnType<typeof openDatabase>) {
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO collection_items (user_id, discogs_release_id, title, artist, year, formats, genres, date_added, rip_override)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
ins.run(1, 'Motion', 'The Cinematic Orchestra', 1999, JSON.stringify(['CD']), JSON.stringify(['Electronic']), '2026-08-01 10:00:00', null)
|
||||||
|
ins.run(2, 'Blue Lines', 'Massive Attack', 1991, JSON.stringify(['Vinyl']), JSON.stringify(['Electronic']), '2026-08-15 10:00:00', 1)
|
||||||
|
ins.run(3, 'Mezzanine', 'Massive Attack', 1998, JSON.stringify(['CD']), JSON.stringify(['Downtempo', 'Electronic']), '2026-08-20 10:00:00', null)
|
||||||
|
ins.run(4, 'Dummy', 'Portishead', 1994, JSON.stringify(['CD', 'Album']), JSON.stringify(['Downtempo']), '2025-09-01 10:00:00', null)
|
||||||
|
db.prepare("INSERT INTO loans (user_id, item_id, borrower) VALUES (1, 2, 'Bob')").run()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/stats', () => {
|
||||||
|
it('aggregates totals, formats, genres, artists, months, ratio, loans', async () => {
|
||||||
|
const db = openDatabase(':memory:')
|
||||||
|
const app = await buildTestAppWithDb(db)
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
// seed after setup so user_id 1 (admin) exists and FKs hold
|
||||||
|
seedItems(db)
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/stats', ...auth(cookie) })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const s = res.json()
|
||||||
|
// only Blue Lines has rip_override=1; no digital albums/match_links → ripped=1, notRipped=3
|
||||||
|
expect(s.totals).toEqual({ items: 4, ripped: 1, notRipped: 3, onLoan: 1 })
|
||||||
|
expect(s.ripRatio).toBeCloseTo(0.25)
|
||||||
|
expect(s.formats).toEqual([
|
||||||
|
{ name: 'CD', count: 3 },
|
||||||
|
{ name: 'Vinyl', count: 1 },
|
||||||
|
{ name: 'Album', count: 1 },
|
||||||
|
])
|
||||||
|
expect(s.topGenres.slice(0, 2)).toEqual([
|
||||||
|
{ name: 'Electronic', count: 3 },
|
||||||
|
{ name: 'Downtempo', count: 2 },
|
||||||
|
])
|
||||||
|
// Massive Attack (2) leads; Cinematic Orchestra and Portishead both 1 → assert lead + total only
|
||||||
|
expect(s.topArtists).toHaveLength(3)
|
||||||
|
expect(s.topArtists[0]).toEqual({ name: 'Massive Attack', count: 2 })
|
||||||
|
// 2026-08 items: Motion, Blue Lines, Mezzanine (Dummy 2025-09 is outside the 12-month window)
|
||||||
|
const aug = s.addedByMonth.find((m: { month: string }) => m.month === '2026-08')
|
||||||
|
expect(aug).toEqual({ month: '2026-08', count: 3 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
203
server/test/stream.test.ts
Normal file
203
server/test/stream.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -118,4 +118,76 @@ describe('SubsonicClient', () => {
|
|||||||
{ id: '2', title: 'Album 2', artist: 'Artist 0' },
|
{ id: '2', title: 'Album 2', artist: 'Artist 0' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('url() builds a raw endpoint URL with auth params', () => {
|
||||||
|
const c = new SubsonicClient({
|
||||||
|
url: 'http://navidrome.local',
|
||||||
|
username: 'sam',
|
||||||
|
password: 'pass',
|
||||||
|
fetchImpl: (async () => subsonicResponse({})) as typeof fetch,
|
||||||
|
})
|
||||||
|
const u = new URL(c.url('stream', { id: 'song-9' }))
|
||||||
|
expect(u.pathname).toBe('/rest/stream')
|
||||||
|
expect(u.searchParams.get('id')).toBe('song-9')
|
||||||
|
expect(u.searchParams.get('u')).toBe('sam')
|
||||||
|
expect(u.searchParams.get('f')).toBe('json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAlbum returns ordered tracks', async () => {
|
||||||
|
const c = new SubsonicClient({
|
||||||
|
url: 'http://x',
|
||||||
|
username: 'sam',
|
||||||
|
password: 'pass',
|
||||||
|
fetchImpl: (async () =>
|
||||||
|
subsonicResponse({
|
||||||
|
'subsonic-response': {
|
||||||
|
status: 'ok',
|
||||||
|
album: {
|
||||||
|
id: 'alb-1',
|
||||||
|
name: 'Motion',
|
||||||
|
artist: 'The Cinematic Orchestra',
|
||||||
|
song: [
|
||||||
|
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
|
||||||
|
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) as typeof fetch,
|
||||||
|
})
|
||||||
|
const album = await c.getAlbum('alb-1')
|
||||||
|
expect(album).toEqual({
|
||||||
|
id: 'alb-1',
|
||||||
|
title: 'Motion',
|
||||||
|
artist: 'The Cinematic Orchestra',
|
||||||
|
tracks: [
|
||||||
|
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
|
||||||
|
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRecentAlbums returns the recent list raw', async () => {
|
||||||
|
const c = new SubsonicClient({
|
||||||
|
url: 'http://x',
|
||||||
|
username: 'sam',
|
||||||
|
password: 'pass',
|
||||||
|
fetchImpl: (async () =>
|
||||||
|
subsonicResponse({
|
||||||
|
'subsonic-response': {
|
||||||
|
status: 'ok',
|
||||||
|
albumList2: {
|
||||||
|
album: [
|
||||||
|
{ id: 'a1', name: 'Motion', artist: 'TCO', played: '2026-09-01T10:00:00Z' },
|
||||||
|
{ id: 'a2', name: 'Blue Lines', artist: 'Massive Attack' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) as typeof fetch,
|
||||||
|
})
|
||||||
|
const recent = await c.getRecentAlbums(500)
|
||||||
|
expect(recent).toEqual([
|
||||||
|
{ id: 'a1', title: 'Motion', artist: 'TCO', playedAt: '2026-09-01T10:00:00Z' },
|
||||||
|
{ id: 'a2', title: 'Blue Lines', artist: 'Massive Attack', playedAt: null },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AuthProvider, useAuth } from './auth'
|
import { AuthProvider, useAuth } from './auth'
|
||||||
import Shell from './shell'
|
import Shell from './shell'
|
||||||
|
import { PlayerProvider } from './player/PlayerContext.js'
|
||||||
|
import MiniBar from './player/MiniBar.js'
|
||||||
import SetupPage from './pages/SetupPage'
|
import SetupPage from './pages/SetupPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import LibraryPage from './pages/LibraryPage'
|
import LibraryPage from './pages/LibraryPage'
|
||||||
@@ -9,6 +11,8 @@ import ItemPage from './pages/ItemPage'
|
|||||||
import ScanPage from './pages/ScanPage'
|
import ScanPage from './pages/ScanPage'
|
||||||
import AddPage from './pages/AddPage'
|
import AddPage from './pages/AddPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import StatsPage from './pages/StatsPage'
|
||||||
|
import QueuePage from './pages/QueuePage'
|
||||||
|
|
||||||
function Gate({ children }: { children: ReactNode }) {
|
function Gate({ children }: { children: ReactNode }) {
|
||||||
const { status } = useAuth()
|
const { status } = useAuth()
|
||||||
@@ -30,7 +34,10 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Gate>
|
<Gate>
|
||||||
|
<PlayerProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
|
<MiniBar />
|
||||||
|
</PlayerProvider>
|
||||||
</Gate>
|
</Gate>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -39,6 +46,8 @@ export default function App() {
|
|||||||
<Route path="/scan" element={<ScanPage />} />
|
<Route path="/scan" element={<ScanPage />} />
|
||||||
<Route path="/add" element={<AddPage />} />
|
<Route path="/add" element={<AddPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/stats" element={<StatsPage />} />
|
||||||
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/library" replace />} />
|
<Route path="*" element={<Navigate to="/library" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AlbumTracks,
|
||||||
|
BackupFile,
|
||||||
Candidate,
|
Candidate,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
DigitalAlbum,
|
DigitalAlbum,
|
||||||
Item,
|
Item,
|
||||||
|
ItemDetail,
|
||||||
|
Loan,
|
||||||
|
LoansResponse,
|
||||||
ReleasePreview,
|
ReleasePreview,
|
||||||
SettingsView,
|
SettingsView,
|
||||||
|
Stats,
|
||||||
SyncState,
|
SyncState,
|
||||||
User,
|
User,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -73,17 +79,18 @@ export const api = {
|
|||||||
),
|
),
|
||||||
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
|
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
|
||||||
|
|
||||||
listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => {
|
listCollection: (params: { format?: string; ripped?: string; q?: string; onLoan?: string } = {}) => {
|
||||||
const usp = new URLSearchParams()
|
const usp = new URLSearchParams()
|
||||||
if (params.format) usp.set('format', params.format)
|
if (params.format) usp.set('format', params.format)
|
||||||
if (params.ripped) usp.set('ripped', params.ripped)
|
if (params.ripped) usp.set('ripped', params.ripped)
|
||||||
if (params.q) usp.set('q', params.q)
|
if (params.q) usp.set('q', params.q)
|
||||||
|
if (params.onLoan) usp.set('onLoan', params.onLoan)
|
||||||
const qs = usp.toString()
|
const qs = usp.toString()
|
||||||
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
|
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
|
||||||
},
|
},
|
||||||
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
|
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
|
||||||
post<Item>('/api/collection', body),
|
post<Item>('/api/collection', body),
|
||||||
getItem: (id: number) => request<Item>(`/api/collection/${id}`),
|
getItem: (id: number) => request<ItemDetail>(`/api/collection/${id}`),
|
||||||
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
|
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
|
||||||
setRip: (id: number, ripped: boolean | null) =>
|
setRip: (id: number, ripped: boolean | null) =>
|
||||||
request<Item>(`/api/collection/${id}/rip`, {
|
request<Item>(`/api/collection/${id}/rip`, {
|
||||||
@@ -97,4 +104,18 @@ export const api = {
|
|||||||
syncStatus: () => request<SyncState>('/api/library/sync'),
|
syncStatus: () => request<SyncState>('/api/library/sync'),
|
||||||
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
||||||
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
|
getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/api/album/${encodeURIComponent(subsonicId)}/tracks`),
|
||||||
|
markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`),
|
||||||
|
streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`,
|
||||||
|
|
||||||
|
getStats: () => request<Stats>('/api/stats'),
|
||||||
|
exportUrl: () => '/api/export',
|
||||||
|
|
||||||
|
lendItem: (id: number, borrower: string) => post<Loan>(`/api/collection/${id}/loan`, { borrower }),
|
||||||
|
getLoans: () => request<LoansResponse>('/api/loans'),
|
||||||
|
returnLoan: (id: number) => post<{ ok: boolean }>(`/api/loans/${id}/return`),
|
||||||
|
|
||||||
|
triggerBackup: () => post<{ file: string }>('/api/backup'),
|
||||||
|
getBackups: () => request<{ backups: BackupFile[] }>('/api/backups'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
import { api, ApiError } from '../api.js'
|
||||||
import type { DigitalAlbum, Item } from '../types.js'
|
import { usePlayer } from '../player/PlayerContext.js'
|
||||||
|
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
||||||
import Cover from '../components/Cover.js'
|
import Cover from '../components/Cover.js'
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (secs < 60) return 'just now'
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
|
||||||
|
const months = Math.floor(days / 30)
|
||||||
|
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
|
||||||
|
return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago`
|
||||||
|
}
|
||||||
|
|
||||||
export default function ItemPage() {
|
export default function ItemPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [item, setItem] = useState<Item | null>(null)
|
const { load } = usePlayer()
|
||||||
|
const [item, setItem] = useState<ItemDetail | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
const [albumQuery, setAlbumQuery] = useState('')
|
const [albumQuery, setAlbumQuery] = useState('')
|
||||||
@@ -16,13 +32,19 @@ export default function ItemPage() {
|
|||||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
void api
|
refetch()
|
||||||
.getItem(Number(id))
|
}, [refetch])
|
||||||
.then(setItem)
|
|
||||||
.catch(() => setError(true))
|
/** Rip/match mutations return a plain Item — keep the detail-only fields. */
|
||||||
}, [id])
|
function applyUpdated(updated: Item) {
|
||||||
|
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
|
||||||
|
}
|
||||||
|
|
||||||
function searchAlbums() {
|
function searchAlbums() {
|
||||||
void api
|
void api
|
||||||
@@ -38,10 +60,7 @@ export default function ItemPage() {
|
|||||||
if (!item) return
|
if (!item) return
|
||||||
void api
|
void api
|
||||||
.setMatch(item.id, albumId)
|
.setMatch(item.id, albumId)
|
||||||
.then((updated) => {
|
.then(() => refetch())
|
||||||
setItem(updated)
|
|
||||||
setMutationError(null)
|
|
||||||
})
|
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +110,19 @@ export default function ItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.ripStatus === 'ripped' && item.matchedAlbum && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
|
||||||
|
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
|
||||||
|
>
|
||||||
|
▶ Play album
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.matchedAlbum?.lastPlayedAt && (
|
||||||
|
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
|
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -101,7 +133,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, false)
|
.setRip(item.id, false)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -117,7 +149,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, true)
|
.setRip(item.id, true)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -134,7 +166,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, null)
|
.setRip(item.id, null)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -209,6 +241,63 @@ export default function ItemPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||||
|
{item.loan ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-neutral-300">
|
||||||
|
Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void api
|
||||||
|
.returnLoan(item.loan!.id)
|
||||||
|
.then(refetch)
|
||||||
|
.catch((err) =>
|
||||||
|
setMutationError(
|
||||||
|
err instanceof ApiError && err.code === 'already_on_loan'
|
||||||
|
? 'That record is already out to someone.'
|
||||||
|
: "That didn't work — check your connection and try again."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Mark returned
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
|
||||||
|
void api
|
||||||
|
.lendItem(item.id, borrower)
|
||||||
|
.then(refetch)
|
||||||
|
.catch((err) =>
|
||||||
|
setMutationError(
|
||||||
|
err instanceof ApiError && err.code === 'already_on_loan'
|
||||||
|
? 'That record is already out to someone.'
|
||||||
|
: "That didn't work — check your connection and try again."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="borrower"
|
||||||
|
aria-label="Borrower"
|
||||||
|
placeholder="Lend to…"
|
||||||
|
required
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300">
|
||||||
|
Lend
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{item.tracklist.length > 0 && (
|
{item.tracklist.length > 0 && (
|
||||||
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
|
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
|
||||||
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
|
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const RIP = ['All', 'Ripped', 'Not ripped'] as const
|
|||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
|
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
|
||||||
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
|
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
|
||||||
|
const [onLoan, setOnLoan] = useState(false)
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [data, setData] = useState<CollectionResponse | null>(null)
|
const [data, setData] = useState<CollectionResponse | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
@@ -20,6 +21,7 @@ export default function LibraryPage() {
|
|||||||
const params = {
|
const params = {
|
||||||
...(format !== 'All' ? { format } : {}),
|
...(format !== 'All' ? { format } : {}),
|
||||||
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
|
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
|
||||||
|
...(onLoan ? { onLoan: 'true' } : {}),
|
||||||
...(q.trim() ? { q: q.trim() } : {}),
|
...(q.trim() ? { q: q.trim() } : {}),
|
||||||
}
|
}
|
||||||
void api
|
void api
|
||||||
@@ -33,7 +35,7 @@ export default function LibraryPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (seq.current === mine) setError(true)
|
if (seq.current === mine) setError(true)
|
||||||
})
|
})
|
||||||
}, [format, ripped, q])
|
}, [format, ripped, onLoan, q])
|
||||||
|
|
||||||
const artists = useMemo(() => {
|
const artists = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
@@ -43,6 +45,15 @@ export default function LibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
<Link to="/stats" className="text-emerald-400">
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
<Link to="/queue" className="text-emerald-400">
|
||||||
|
Queue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search title or artist"
|
placeholder="Search title or artist"
|
||||||
@@ -76,6 +87,15 @@ export default function LibraryPage() {
|
|||||||
{r}
|
{r}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOnLoan((v) => !v)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
onLoan ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
On loan
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-400">Could not load your collection.</p>}
|
{error && <p className="text-sm text-red-400">Could not load your collection.</p>}
|
||||||
|
|||||||
61
web/src/pages/QueuePage.tsx
Normal file
61
web/src/pages/QueuePage.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import type { Item } from '../types.js'
|
||||||
|
import Cover from '../components/Cover.js'
|
||||||
|
|
||||||
|
export default function QueuePage() {
|
||||||
|
const [items, setItems] = useState<Item[] | null>(null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api
|
||||||
|
.listCollection({ ripped: 'not_ripped' })
|
||||||
|
.then((res) => setItems([...res.items].reverse()))
|
||||||
|
.catch(() => setError(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function markRipped(id: number) {
|
||||||
|
void api
|
||||||
|
.setRip(id, true)
|
||||||
|
.then(() => setItems((list) => (list ?? []).filter((i) => i.id !== id)))
|
||||||
|
.catch(() => setError(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-neutral-400">Rip queue — oldest additions first.</p>
|
||||||
|
{error && <p className="text-sm text-red-400">Could not load the queue.</p>}
|
||||||
|
{items && items.length === 0 && (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-neutral-400">Nothing waiting to be ripped.</p>
|
||||||
|
<Link to="/scan" className="mt-2 inline-block text-sm text-emerald-400">
|
||||||
|
Scan something →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items &&
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||||
|
<Cover src={item.artworkUrl} alt="" className="size-12 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||||
|
<p className="truncate text-xs text-neutral-400">
|
||||||
|
{item.artist} · added {new Date(item.dateAdded + 'Z').toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markRipped(item.id)}
|
||||||
|
className="shrink-0 rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950"
|
||||||
|
>
|
||||||
|
Mark ripped
|
||||||
|
</button>
|
||||||
|
<Link to={`/item/${item.id}`} className="shrink-0 text-xs text-neutral-400">
|
||||||
|
details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNod
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { useAuth } from '../auth.js'
|
import { useAuth } from '../auth.js'
|
||||||
import type { SettingsView, SyncState, User } from '../types.js'
|
import type { BackupFile, SettingsView, SyncState, User } from '../types.js'
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +29,7 @@ export default function SettingsPage() {
|
|||||||
const [sync, setSync] = useState<SyncState | null>(null)
|
const [sync, setSync] = useState<SyncState | null>(null)
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[] | null>(null)
|
const [users, setUsers] = useState<User[] | null>(null)
|
||||||
|
const [backups, setBackups] = useState<BackupFile[] | null>(null)
|
||||||
const [newUsername, setNewUsername] = useState('')
|
const [newUsername, setNewUsername] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export default function SettingsPage() {
|
|||||||
.listUsers()
|
.listUsers()
|
||||||
.then((res) => setUsers(res.users))
|
.then((res) => setUsers(res.users))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
@@ -237,6 +239,46 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Data">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={api.exportUrl()}
|
||||||
|
download
|
||||||
|
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</a>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void api
|
||||||
|
.triggerBackup()
|
||||||
|
.then(() => api.getBackups())
|
||||||
|
.then((res) => setBackups(res.backups))
|
||||||
|
.then(() => flash('ok', 'Backup created'))
|
||||||
|
.catch(() => flash('error', 'Backup failed'))
|
||||||
|
}
|
||||||
|
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Back up now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user?.isAdmin && backups && backups.length > 0 && (
|
||||||
|
<ul className="space-y-1 text-xs text-neutral-400">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<li key={b.file} className="flex justify-between gap-2">
|
||||||
|
<span className="truncate">{b.file}</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{user?.isAdmin && (
|
{user?.isAdmin && (
|
||||||
<Section title="Users">
|
<Section title="Users">
|
||||||
<ul className="space-y-1.5 text-sm">
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
|||||||
111
web/src/pages/StatsPage.tsx
Normal file
111
web/src/pages/StatsPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import type { Stats } from '../types.js'
|
||||||
|
|
||||||
|
function Bar({ name, count, max }: { name: string; count: number; max: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-28 shrink-0 truncate text-neutral-300">{name}</span>
|
||||||
|
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-neutral-800">
|
||||||
|
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${max === 0 ? 0 : (count / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-8 shrink-0 text-right text-neutral-500">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ value, label }: { value: string | number; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-center">
|
||||||
|
<p className="text-2xl font-semibold">{value}</p>
|
||||||
|
<p className="text-xs text-neutral-400">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsPage() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api
|
||||||
|
.getStats()
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => setError(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) return <p className="py-8 text-center text-sm text-red-400">Could not load stats.</p>
|
||||||
|
if (!stats) return <p className="py-8 text-center text-sm text-neutral-400">Loading…</p>
|
||||||
|
|
||||||
|
const maxOf = (rows: { count: number }[]) => Math.max(1, ...rows.map((r) => r.count))
|
||||||
|
const maxMonth = Math.max(1, ...stats.addedByMonth.map((m) => m.count))
|
||||||
|
const ratioPct = Math.round(stats.ripRatio * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Card value={stats.totals.items} label="in collection" />
|
||||||
|
<Card value={stats.totals.ripped} label="ripped" />
|
||||||
|
<Card value={stats.totals.notRipped} label="not ripped" />
|
||||||
|
<Card value={stats.totals.onLoan} label="on loan" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<div
|
||||||
|
className="size-20 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#34d399 ${ratioPct}%, #262626 ${ratioPct}% 100%)`,
|
||||||
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label={`rip ratio ${ratioPct}%`}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-neutral-300">{ratioPct}% ripped</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Formats</h2>
|
||||||
|
{stats.formats.length === 0 && <p className="text-sm text-neutral-500">No data yet.</p>}
|
||||||
|
{stats.formats.map((f) => (
|
||||||
|
<Bar key={f.name} name={f.name} count={f.count} max={maxOf(stats.formats)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Genres</h2>
|
||||||
|
{stats.topGenres.map((g) => (
|
||||||
|
<Bar key={g.name} name={g.name} count={g.count} max={maxOf(stats.topGenres)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Top artists</h2>
|
||||||
|
{stats.topArtists.map((a) => (
|
||||||
|
<Bar key={a.name} name={a.name} count={a.count} max={maxOf(stats.topArtists)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Decades</h2>
|
||||||
|
{stats.decades.map((d) => (
|
||||||
|
<Bar key={d.name} name={d.name} count={d.count} max={maxOf(stats.decades)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Added per month</h2>
|
||||||
|
<div className="flex h-24 items-end gap-1">
|
||||||
|
{stats.addedByMonth.map((m) => (
|
||||||
|
<div key={m.month} className="flex flex-1 flex-col items-center gap-1" title={`${m.month}: ${m.count}`}>
|
||||||
|
<div
|
||||||
|
data-bar
|
||||||
|
className="w-full rounded-t bg-emerald-500"
|
||||||
|
style={{ height: `${(m.count / maxMonth) * 100}%`, minHeight: m.count > 0 ? '4px' : '1px', background: m.count > 0 ? '#34d399' : '#262626' }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-neutral-500">{m.month.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
web/src/player/MiniBar.tsx
Normal file
92
web/src/player/MiniBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
web/src/player/PlayerContext.tsx
Normal file
107
web/src/player/PlayerContext.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -68,3 +68,49 @@ export interface DigitalAlbum {
|
|||||||
title: string
|
title: string
|
||||||
artist: string
|
artist: string
|
||||||
}
|
}
|
||||||
|
export interface Track {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
duration: number | null
|
||||||
|
track: number | null
|
||||||
|
}
|
||||||
|
export interface AlbumTracks {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
tracks: Track[]
|
||||||
|
}
|
||||||
|
export interface MatchedAlbum {
|
||||||
|
id: number
|
||||||
|
subsonicId: string
|
||||||
|
lastPlayedAt: string | null
|
||||||
|
}
|
||||||
|
export interface Stats {
|
||||||
|
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
||||||
|
ripRatio: number
|
||||||
|
formats: { name: string; count: number }[]
|
||||||
|
decades: { name: string; count: number }[]
|
||||||
|
topGenres: { name: string; count: number }[]
|
||||||
|
topArtists: { name: string; count: number }[]
|
||||||
|
addedByMonth: { month: string; count: number }[]
|
||||||
|
}
|
||||||
|
export interface Loan {
|
||||||
|
id: number
|
||||||
|
itemId: number
|
||||||
|
borrower: string
|
||||||
|
lentAt: string
|
||||||
|
returnedAt: string | null
|
||||||
|
}
|
||||||
|
export interface LoansResponse {
|
||||||
|
active: Loan[]
|
||||||
|
history: Loan[]
|
||||||
|
}
|
||||||
|
export interface BackupFile {
|
||||||
|
file: string
|
||||||
|
sizeBytes: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
export interface ItemDetail extends Item {
|
||||||
|
matchedAlbum: MatchedAlbum | null
|
||||||
|
loan: { id: number; borrower: string; lentAt: string } | null
|
||||||
|
}
|
||||||
|
|||||||
43
web/test/api.test.ts
Normal file
43
web/test/api.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,19 +3,31 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import ItemPage from '../src/pages/ItemPage.js'
|
import ItemPage from '../src/pages/ItemPage.js'
|
||||||
import type { Item } from '../src/types.js'
|
import { PlayerProvider } from '../src/player/PlayerContext.js'
|
||||||
|
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
||||||
|
|
||||||
vi.mock('../src/api.js', async (importOriginal) => {
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../src/api.js')>()
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() },
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setRip: vi.fn(),
|
||||||
|
setMatch: vi.fn(),
|
||||||
|
deleteItem: vi.fn(),
|
||||||
|
searchAlbums: vi.fn(),
|
||||||
|
getAlbumTracks: vi.fn(),
|
||||||
|
markPlayed: vi.fn(),
|
||||||
|
lendItem: vi.fn(),
|
||||||
|
returnLoan: vi.fn(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
import { api } from '../src/api.js'
|
import { api, ApiError } from '../src/api.js'
|
||||||
|
|
||||||
const item: Item = {
|
const item: ItemDetail = {
|
||||||
id: 1,
|
id: 1,
|
||||||
discogsReleaseId: 1001,
|
discogsReleaseId: 1001,
|
||||||
title: 'Motion',
|
title: 'Motion',
|
||||||
@@ -32,24 +44,37 @@ const item: Item = {
|
|||||||
dateAdded: '2026-08-29',
|
dateAdded: '2026-08-29',
|
||||||
ripOverride: null,
|
ripOverride: null,
|
||||||
ripStatus: 'not_ripped',
|
ripStatus: 'not_ripped',
|
||||||
|
matchedAlbum: null,
|
||||||
|
loan: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
|
||||||
|
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
||||||
vi.mocked(api.getItem).mockReset()
|
vi.mocked(api.getItem).mockReset()
|
||||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||||
vi.mocked(api.setRip).mockReset()
|
vi.mocked(api.setRip).mockReset()
|
||||||
vi.mocked(api.setMatch).mockReset()
|
vi.mocked(api.setMatch).mockReset()
|
||||||
vi.mocked(api.deleteItem).mockReset()
|
vi.mocked(api.deleteItem).mockReset()
|
||||||
vi.mocked(api.searchAlbums).mockReset()
|
vi.mocked(api.searchAlbums).mockReset()
|
||||||
|
vi.mocked(api.getAlbumTracks).mockReset()
|
||||||
|
vi.mocked(api.markPlayed).mockReset()
|
||||||
|
vi.mocked(api.lendItem).mockReset()
|
||||||
|
vi.mocked(api.returnLoan).mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderItem() {
|
function renderItem() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/item/1']}>
|
<MemoryRouter initialEntries={['/item/1']}>
|
||||||
|
<PlayerProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/item/:id" element={<ItemPage />} />
|
<Route path="/item/:id" element={<ItemPage />} />
|
||||||
<Route path="/library" element={<p>library</p>} />
|
<Route path="/library" element={<p>library</p>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</PlayerProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,6 +119,27 @@ describe('ItemPage', () => {
|
|||||||
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
|
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('re-match refreshes the play button state', async () => {
|
||||||
|
vi.mocked(api.getItem)
|
||||||
|
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
||||||
|
.mockResolvedValue({ ...rippedItem } as never)
|
||||||
|
vi.mocked(api.searchAlbums).mockResolvedValue({
|
||||||
|
albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
|
||||||
|
} as never)
|
||||||
|
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
||||||
|
renderItem()
|
||||||
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
||||||
|
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /re-match/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
||||||
|
const albumRadio = await screen.findByRole('radio', { name: /motion \(remaster\)/i })
|
||||||
|
await userEvent.click(albumRadio)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /play album/i })).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
it('unlink clears the match', async () => {
|
it('unlink clears the match', async () => {
|
||||||
vi.mocked(api.setMatch).mockResolvedValue(item as never)
|
vi.mocked(api.setMatch).mockResolvedValue(item as never)
|
||||||
renderItem()
|
renderItem()
|
||||||
@@ -142,4 +188,51 @@ describe('ItemPage', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
||||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows Play for a ripped item with a matched album and loads the player', async () => {
|
||||||
|
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
|
||||||
|
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
|
||||||
|
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
|
||||||
|
renderItem()
|
||||||
|
const play = await screen.findByRole('button', { name: /play album/i })
|
||||||
|
await userEvent.click(play)
|
||||||
|
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
|
||||||
|
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Play when unmatched or not ripped', async () => {
|
||||||
|
renderItem()
|
||||||
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
||||||
|
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows last played under the rip banner', async () => {
|
||||||
|
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
|
||||||
|
renderItem()
|
||||||
|
expect(await screen.findByText(/last played/i)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lend and return flow', async () => {
|
||||||
|
vi.mocked(api.getItem)
|
||||||
|
.mockResolvedValueOnce(rippedItem as never)
|
||||||
|
.mockResolvedValueOnce({ ...rippedItem, loan: { id: 9, borrower: 'Bob', lentAt: '2026-09-03' } } as never)
|
||||||
|
vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never)
|
||||||
|
renderItem()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
|
||||||
|
await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob'))
|
||||||
|
|
||||||
|
vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
|
||||||
|
await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
|
||||||
|
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lend failure surfaces the inline message', async () => {
|
||||||
|
vi.mocked(api.lendItem).mockRejectedValue(new ApiError(409, 'already_on_loan'))
|
||||||
|
renderItem()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/already out to someone/i)).toBeTruthy())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -110,4 +110,23 @@ describe('LibraryPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
|
await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
|
||||||
expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add')
|
expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('header links to stats and queue', async () => {
|
||||||
|
renderLibrary()
|
||||||
|
expect(screen.getByRole('link', { name: /stats/i }).getAttribute('href')).toBe('/stats')
|
||||||
|
expect(screen.getByRole('link', { name: /queue/i }).getAttribute('href')).toBe('/queue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on-loan chip filters by loan state', async () => {
|
||||||
|
renderLibrary()
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /^on loan$/i })).toBeTruthy())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ onLoan: 'true' }))
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.listCollection).toHaveBeenLastCalledWith(expect.not.objectContaining({ onLoan: expect.anything() }))
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
81
web/test/minibar.test.tsx
Normal file
81
web/test/minibar.test.tsx
Normal 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
135
web/test/player.test.tsx
Normal 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
56
web/test/queue.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import QueuePage from '../src/pages/QueuePage.js'
|
||||||
|
import type { Item } from '../src/types.js'
|
||||||
|
|
||||||
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
|
return { ...actual, api: { ...actual.api, listCollection: vi.fn(), setRip: vi.fn() } }
|
||||||
|
})
|
||||||
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
|
function qItem(id: number, title: string, added: string): Item {
|
||||||
|
return {
|
||||||
|
id, discogsReleaseId: 1, title, artist: 'Artist', year: 1999, formats: ['CD'], genres: [], labels: [],
|
||||||
|
tracklist: [], catno: null, country: null, artworkUrl: null, barcodes: [], dateAdded: added,
|
||||||
|
ripOverride: null, ripStatus: 'not_ripped',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.listCollection).mockReset()
|
||||||
|
vi.mocked(api.setRip).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('QueuePage', () => {
|
||||||
|
it('lists not-ripped items oldest first and marks ripped', async () => {
|
||||||
|
vi.mocked(api.listCollection).mockResolvedValue({
|
||||||
|
items: [qItem(1, 'Newest', '2026-08-20'), qItem(2, 'Oldest', '2026-08-01')],
|
||||||
|
counts: { total: 2, ripped: 0, notRipped: 2 },
|
||||||
|
} as never)
|
||||||
|
vi.mocked(api.setRip).mockResolvedValue({} as never)
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueuePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Oldest')).toBeTruthy() // oldest first
|
||||||
|
const rows = screen.getAllByRole('button', { name: /mark ripped/i })
|
||||||
|
await userEvent.click(rows[0]!)
|
||||||
|
await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(2, true))
|
||||||
|
await waitFor(() => expect(screen.queryByText('Oldest')).toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty state points back to scanning', async () => {
|
||||||
|
vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueuePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText(/nothing waiting/i)).toBeTruthy()
|
||||||
|
expect(screen.getByRole('link', { name: /scan something/i }).getAttribute('href')).toBe('/scan')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,8 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
|||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
triggerBackup: vi.fn(),
|
||||||
|
getBackups: vi.fn(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) {
|
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout, api.triggerBackup, api.getBackups] as const) {
|
||||||
vi.mocked(fn).mockReset()
|
vi.mocked(fn).mockReset()
|
||||||
}
|
}
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
||||||
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
||||||
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
||||||
|
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
|
||||||
stubAuthFetch(admin)
|
stubAuthFetch(admin)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,4 +182,30 @@ describe('SettingsPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
||||||
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
|
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('data section: export link, backup now, backups list', async () => {
|
||||||
|
vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never)
|
||||||
|
vi.mocked(api.getBackups).mockResolvedValue({
|
||||||
|
backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }],
|
||||||
|
} as never)
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /back up now/i }))
|
||||||
|
await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled())
|
||||||
|
await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backup failure surfaces in the flash banner', async () => {
|
||||||
|
vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed'))
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-admin sees the Export link but not backups', async () => {
|
||||||
|
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('link', { name: /export json/i })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: /back up now/i })).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
83
web/test/stats.test.tsx
Normal file
83
web/test/stats.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import StatsPage from '../src/pages/StatsPage.js'
|
||||||
|
import type { Stats } from '../src/types.js'
|
||||||
|
|
||||||
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
|
return { ...actual, api: { ...actual.api, getStats: vi.fn() } }
|
||||||
|
})
|
||||||
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
|
const stats: Stats = {
|
||||||
|
totals: { items: 4, ripped: 1, notRipped: 3, onLoan: 1 },
|
||||||
|
ripRatio: 0.25,
|
||||||
|
formats: [
|
||||||
|
{ name: 'CD', count: 3 },
|
||||||
|
{ name: 'Vinyl', count: 1 },
|
||||||
|
],
|
||||||
|
decades: [{ name: '1990s', count: 4 }],
|
||||||
|
topGenres: [
|
||||||
|
{ name: 'Electronic', count: 3 },
|
||||||
|
{ name: 'Downtempo', count: 2 },
|
||||||
|
],
|
||||||
|
topArtists: [{ name: 'Massive Attack', count: 2 }],
|
||||||
|
addedByMonth: [
|
||||||
|
{ month: '2025-10', count: 0 },
|
||||||
|
{ month: '2025-11', count: 0 },
|
||||||
|
{ month: '2025-12', count: 0 },
|
||||||
|
{ month: '2026-01', count: 0 },
|
||||||
|
{ month: '2026-02', count: 0 },
|
||||||
|
{ month: '2026-03', count: 0 },
|
||||||
|
{ month: '2026-04', count: 0 },
|
||||||
|
{ month: '2026-05', count: 0 },
|
||||||
|
{ month: '2026-06', count: 0 },
|
||||||
|
{ month: '2026-07', count: 0 },
|
||||||
|
{ month: '2026-08', count: 4 },
|
||||||
|
{ month: '2026-09', count: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.getStats).mockReset()
|
||||||
|
vi.mocked(api.getStats).mockResolvedValue(stats as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('StatsPage', () => {
|
||||||
|
it('renders totals and rip ratio', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect((await screen.findAllByText('4')).length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(screen.getByText(/25% ripped/i)).toBeTruthy()
|
||||||
|
const onLoanLabel = screen.getByText('on loan')
|
||||||
|
expect(within(onLoanLabel.parentElement as HTMLElement).getByText('1')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders format, genre, artist and month bars', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('CD')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Electronic')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Massive Attack')).toBeTruthy()
|
||||||
|
expect(screen.getByText('1990s')).toBeTruthy()
|
||||||
|
// month bars: 12 bars rendered
|
||||||
|
expect(document.querySelectorAll('[data-bar]').length).toBeGreaterThanOrEqual(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error state on failure', async () => {
|
||||||
|
vi.mocked(api.getStats).mockRejectedValue(new TypeError('fetch failed'))
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<StatsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText(/could not load stats/i)).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user