2583 lines
100 KiB
Markdown
2583 lines
100 KiB
Markdown
|
|
# Wave 1 Implementation Plan (plan 3)
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Ship Wave 1: schema versioning + live migration, built-in album player with last-played tracking, loan tracking, rip queue, stats wall, and JSON export + SQLite backups.
|
||
|
|
|
||
|
|
**Architecture:** Extends the existing Fastify/SQLite backend (server/) and React SPA (web/) without changing the frozen plan-1 API. New routes: stream/album proxy, loans, stats, export/backup. First schema migration via `app_meta.schema_version`. Player is a global context above the router so audio survives navigation.
|
||
|
|
|
||
|
|
**Tech Stack:** unchanged (Fastify 5, better-sqlite3, React 19, Vite, Vitest). Spec: `docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md`.
|
||
|
|
|
||
|
|
**Prerequisites:** main (post plan 2), 147/147 tests. Branch: `wave1-hygiene-listening`. Node >= 20.
|
||
|
|
|
||
|
|
**Stamping refinement vs spec:** Subsonic's recent-list entries don't reliably carry a played timestamp. `last_played_at` is therefore stamped from two sources: (1) sync, when an album entry reports one (`played`/`playedAt` field, OpenSubsonic-style — opportunistic, null-safe), and (2) the built-in player, via `POST /api/album/:subsonicId/played` on playback start. UX is identical.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File structure (plan 3)
|
||
|
|
|
||
|
|
```
|
||
|
|
server/src/
|
||
|
|
├── db.ts # MODIFY: versioned migrate(), loans table, last_played_at
|
||
|
|
├── config.ts # MODIFY: backupsDir
|
||
|
|
├── subsonic.ts # MODIFY: getAlbum(), getRecentAlbums(), public url()
|
||
|
|
├── sync.ts # MODIFY: stamp last_played_at
|
||
|
|
├── ripstatus.ts # MODIFY: findMatchedAlbum()
|
||
|
|
├── routes/
|
||
|
|
│ ├── collectionRoutes.ts # MODIFY: detail gains matchedAlbum, onLoan filter
|
||
|
|
│ ├── streamRoutes.ts # NEW: /api/stream/:songId, /api/album/:id/tracks, /api/album/:id/played
|
||
|
|
│ ├── loanRoutes.ts # NEW: loans CRUD
|
||
|
|
│ ├── statsRoutes.ts # NEW: /api/stats
|
||
|
|
│ └── dataRoutes.ts # NEW: /api/export, /api/backup, /api/backups
|
||
|
|
└── test/ (new: migrate.test.ts, loans.test.ts, stats.test.ts, data.test.ts, stream.test.ts; modified: subsonic.test.ts, sync.test.ts, collection.test.ts)
|
||
|
|
web/src/
|
||
|
|
├── types.ts # MODIFY: Track, Stats, Loan, Backups, matchedAlbum
|
||
|
|
├── api.ts # MODIFY: new methods
|
||
|
|
├── player/PlayerContext.tsx # NEW: global player state + <audio>
|
||
|
|
├── player/MiniBar.tsx # NEW: mini-bar + expanded panel
|
||
|
|
├── pages/ItemPage.tsx # MODIFY: Play, last played, loan section
|
||
|
|
├── pages/LibraryPage.tsx # MODIFY: header links, On loan chip
|
||
|
|
├── pages/QueuePage.tsx # NEW
|
||
|
|
├── pages/StatsPage.tsx # NEW
|
||
|
|
├── pages/SettingsPage.tsx # MODIFY: Data section
|
||
|
|
├── App.tsx # MODIFY: routes + PlayerProvider
|
||
|
|
└── test/ (new: player.test.tsx, queue.test.tsx, stats.test.tsx; modified: item, library, settings, api tests)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Schema versioning — migration v2
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `server/src/db.ts`, `server/src/config.ts`
|
||
|
|
- Test: `server/test/migrate.test.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `server/test/migrate.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||
|
|
import Database from 'better-sqlite3'
|
||
|
|
import { openDatabase } 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()
|
||
|
|
// run the exported upgrade path against the legacy db
|
||
|
|
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')
|
||
|
|
})
|
||
|
|
|
||
|
|
let migrateUpgrades: (db: Database.Database) => void
|
||
|
|
beforeEach(() => {
|
||
|
|
// bound below after import — keeps the legacy-db helper readable
|
||
|
|
;({ migrateUpgrades } = require('../src/db.js'))
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: the project is ESM — replace the `require` trick with a normal top-level import: `import { openDatabase, migrateUpgrades } from '../src/db.js'` and drop the beforeEach binding. The helper name must match what db.ts exports.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/migrate.test.ts`
|
||
|
|
Expected: FAIL — `migrateUpgrades` is not exported.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement — modify `server/src/db.ts`**
|
||
|
|
|
||
|
|
The current `migrate(db)` execs one big schema string. Restructure: keep that string as `BASE_SCHEMA` (rename the exec target; content unchanged — v1 schema exactly), add the versioned upgrade path, and export `migrateUpgrades`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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) {
|
||
|
|
db.exec(`
|
||
|
|
ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT;
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function migrate(db: DB): void {
|
||
|
|
db.exec(BASE_SCHEMA)
|
||
|
|
migrateUpgrades(db)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(`migrate` is called from `openDatabase` as before; `BASE_SCHEMA` is the existing DDL string, unchanged.)
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add `backupsDir` to `server/src/config.ts`** — extend `Config` with `backupsDir: string` and in `loadConfig`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const backupsDir = path.join(dataDir, 'backups')
|
||
|
|
mkdirSync(backupsDir, { recursive: true })
|
||
|
|
```
|
||
|
|
(return `backupsDir` in the object; add `backupsDir: ':memory:'`-style literal to `server/test/helpers.ts` testConfig and the inline config in `server/test/app.test.ts` — real temp dir like artworkDir: `mkdtempSync(path.join(tmpdir(), 'rs-bak-'))`.)
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/migrate.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (147 + 3 new = 150). Any test constructing a raw config literal needs `backupsDir` added — fix those (app.test.ts, helpers.ts).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: schema versioning with v2 migration (loans, last_played_at)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Subsonic client — getAlbum, getRecentAlbums, public url()
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `server/src/subsonic.ts`
|
||
|
|
- Test: `server/test/subsonic.test.ts` (append)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append failing tests to `server/test/subsonic.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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 },
|
||
|
|
])
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/subsonic.test.ts`
|
||
|
|
Expected: FAIL — url/getAlbum/getRecentAlbums not implemented.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement in `server/src/subsonic.ts`** — add to the class:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
/** 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,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/subsonic.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (150 + 3 new = 153).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: subsonic getAlbum/getRecentAlbums and stream url builder"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Sync stamps last_played_at + played endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `server/src/sync.ts`, `server/src/routes/streamRoutes.ts` (created in this task), `server/src/app.ts`
|
||
|
|
- Test: `server/test/sync.test.ts` (new — move existing sync tests? No: library.test.ts holds sync tests; create `server/test/lastplayed.test.ts` instead)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `server/test/lastplayed.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/lastplayed.test.ts`
|
||
|
|
Expected: FAIL — played endpoint 404; sync doesn't stamp.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Modify `server/src/sync.ts` `run()`** — after the existing stale-deletion loop, inside the same transaction is not required (best-effort stamping):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// 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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create `server/src/routes/streamRoutes.ts`** — full file:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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 }
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Register in `server/src/app.ts`** — import + `await registerStreamRoutes(app)` after `registerLibraryRoutes(app)`.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/lastplayed.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (153 + 2 new = 155).
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: last-played stamping via sync and played endpoint, stream/album proxy routes"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Item detail gains matchedAlbum; list gains onLoan filter
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `server/src/ripstatus.ts`, `server/src/routes/collectionRoutes.ts`
|
||
|
|
- Test: `server/test/ripstatus.test.ts` (append), `server/test/collection.test.ts` (append)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append failing test to `server/test/ripstatus.test.ts`** (the file has a `db` fixture with users/items helpers — reuse `addAlbum`):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { findMatchedAlbum } from '../src/ripstatus.js'
|
||
|
|
|
||
|
|
describe('findMatchedAlbum', () => {
|
||
|
|
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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/ripstatus.test.ts`
|
||
|
|
Expected: FAIL — findMatchedAlbum not exported.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement `findMatchedAlbum` in `server/src/ripstatus.ts`** (after `resolveRipStatus`):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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 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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Append failing tests to `server/test/collection.test.ts`** (reuse the file's `appWithToken`, `discogsStub`, `auth` helpers; use the test-seed route for albums):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('list supports onLoan=true 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()
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
(Note: the onLoan test depends on Task 5's loan route — it is a known-red handoff inside this task; Task 5 turns it green. Verify everything else passes here.)
|
||
|
|
|
||
|
|
- [ ] **Step 5: Implement** — in `server/src/routes/collectionRoutes.ts`:
|
||
|
|
|
||
|
|
Detail route: after `rowToItem`, attach matchedAlbum:
|
||
|
|
```ts
|
||
|
|
import { resolveRipStatus, findMatchedAlbum } from '../ripstatus.js'
|
||
|
|
```
|
||
|
|
```ts
|
||
|
|
const row = getItem(db, request.user.id, Number((request.params as { id: string }).id))
|
||
|
|
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||
|
|
return { ...rowToItem(db, row), matchedAlbum: findMatchedAlbum(db, request.user.id, row.id) }
|
||
|
|
```
|
||
|
|
|
||
|
|
List route: extend the query destructure with `onLoan` and filter before returning (after the `q` filter):
|
||
|
|
```ts
|
||
|
|
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
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
and the query type gains `onLoan?: string`.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/ripstatus.test.ts server/test/collection.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS except the onLoan handoff test (160 total = 155 + 5 new; 1 known-red).
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: matchedAlbum on item detail, onLoan collection filter"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Loan routes
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `server/src/routes/loanRoutes.ts`
|
||
|
|
- Test: `server/test/loans.test.ts`
|
||
|
|
- Modify: `server/src/app.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `server/test/loans.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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('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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/loans.test.ts`
|
||
|
|
Expected: FAIL — 404s on loan routes.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `server/src/routes/loanRoutes.ts`** — full file:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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 }
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register in `server/src/app.ts`** — import + `await registerLoanRoutes(app)` after `registerStreamRoutes(app)`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/loans.test.ts server/test/collection.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (160 + 2 new = 162; the Task 4 onLoan handoff test now green).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: loan tracking routes"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: Stats route
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `server/src/routes/statsRoutes.ts`
|
||
|
|
- Test: `server/test/stats.test.ts`
|
||
|
|
- Modify: `server/src/app.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `server/test/stats.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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:')
|
||
|
|
seedItems(db)
|
||
|
|
const app = await buildTestAppWithDb(db)
|
||
|
|
const cookie = await setupAdmin(app)
|
||
|
|
const res = await app.inject({ method: 'GET', url: '/api/stats', ...auth(cookie) })
|
||
|
|
expect(res.statusCode).toBe(200)
|
||
|
|
const s = res.json()
|
||
|
|
expect(s.totals).toEqual({ items: 4, ripped: 2, notRipped: 2, onLoan: 1 })
|
||
|
|
expect(s.ripRatio).toBeCloseTo(0.5)
|
||
|
|
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 },
|
||
|
|
])
|
||
|
|
expect(s.topArtists.slice(0, 2)).toEqual([
|
||
|
|
{ name: 'Massive Attack', count: 2 },
|
||
|
|
{ name: 'Portishead', count: 1 },
|
||
|
|
])
|
||
|
|
const aug = s.addedByMonth.find((m: { month: string }) => m.month === '2026-08')
|
||
|
|
expect(aug).toEqual({ month: '2026-08', count: 3 })
|
||
|
|
await app.close()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: rip counts in this fixture come from rip_override (Blue Lines override 1 = ripped; others auto → no digital albums → not_ripped). `Motion` and `Mezzanine` and `Dummy` are not_ripped → totals {4, 2 ripped? no...}. Recount: overrides — item 2 override 1 → ripped; items 1,3,4 override null + no albums → not_ripped. So ripped=1, notRipped=3, ratio 0.25. FIX the expected values: totals {items:4, ripped:1, notRipped:3, onLoan:1}, ripRatio 0.25, and `topArtists` Massive Attack 2 first. Use these corrected expectations in the final test.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/stats.test.ts`
|
||
|
|
Expected: FAIL — 404.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `server/src/routes/statsRoutes.ts`** — full file:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { FastifyInstance } from 'fastify'
|
||
|
|
import { requireAuth } from './authRoutes.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 rippedCounts = { ripped: 0, notRipped: 0 }
|
||
|
|
const formats: string[] = []
|
||
|
|
const genres: string[] = []
|
||
|
|
const months = new Map<string, number>()
|
||
|
|
for (const item of items) {
|
||
|
|
let ripped: boolean
|
||
|
|
if (item.rip_override !== null && item.rip_override !== undefined) {
|
||
|
|
ripped = item.rip_override === 1
|
||
|
|
} else {
|
||
|
|
ripped = !!db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(item.id) ||
|
||
|
|
!!db
|
||
|
|
.prepare('SELECT id FROM digital_albums WHERE user_id = ?')
|
||
|
|
.all(userId)
|
||
|
|
.some((a: any) => a.title === item.title && a.artist === item.artist)
|
||
|
|
}
|
||
|
|
ripped ? rippedCounts.ripped++ : rippedCounts.notRipped++
|
||
|
|
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 })
|
||
|
|
}
|
||
|
|
|
||
|
|
const artists = countBy(items.map((i) => i.artist)).slice(0, 10)
|
||
|
|
const decades = countBy(items.map((i) => (i.year ? `${Math.floor(i.year / 10) * 10}s` : '')))
|
||
|
|
|
||
|
|
return {
|
||
|
|
totals: {
|
||
|
|
items: items.length,
|
||
|
|
ripped: rippedCounts.ripped,
|
||
|
|
notRipped: rippedCounts.notRipped,
|
||
|
|
onLoan,
|
||
|
|
},
|
||
|
|
ripRatio: items.length === 0 ? 0 : rippedCounts.ripped / items.length,
|
||
|
|
formats: countBy(formats),
|
||
|
|
decades,
|
||
|
|
topGenres: countBy(genres).slice(0, 10),
|
||
|
|
topArtists: artists,
|
||
|
|
addedByMonth,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
IMPORTANT consistency decision: the rip determination here reimplements resolution loosely (title/artist equality, no normalize). REUSE the real logic instead: import `resolveRipStatusBatch` from '../ripstatus.js' (plan-2 fix) and compute statuses for all items in one call:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const statuses = resolveRipStatusBatch(db, userId, items)
|
||
|
|
items.forEach((item, i) => { ripped = statuses[i] === 'ripped' })
|
||
|
|
```
|
||
|
|
Implement with the batch call (zip items+statuses); drop the inline fuzzy check. The test expectations stand as corrected above.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register in `server/src/app.ts`** — import + `await registerStatsRoutes(app)` after `registerLoanRoutes(app)`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/stats.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (162 + 1 new = 163).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: collection stats route"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Export + backup routes
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `server/src/routes/dataRoutes.ts`
|
||
|
|
- Test: `server/test/data.test.ts`
|
||
|
|
- Modify: `server/src/app.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `server/test/data.test.ts`**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { describe, it, expect } from 'vitest'
|
||
|
|
import { readdirSync } from 'node:fs'
|
||
|
|
import path from 'node:path'
|
||
|
|
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' }] },
|
||
|
|
})
|
||
|
|
await app.inject({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/api/collection/1/loan',
|
||
|
|
...auth(cookie),
|
||
|
|
payload: { borrower: 'Bob' },
|
||
|
|
}).catch(() => {})
|
||
|
|
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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: `/api/collection/1/loan` against a fresh app (no items) returns 404 — the `.catch(() => {})` guard on inject is wrong (inject doesn't reject); just fire it without the catch and ignore the status, or drop that call — export asserts empty arrays anyway. Simplify: remove the loan call from `appWithData`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/data.test.ts`
|
||
|
|
Expected: FAIL — 404s.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `server/src/routes/dataRoutes.ts`** — full file:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { FastifyInstance } from 'fastify'
|
||
|
|
import { 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)
|
||
|
|
const dest = path.join(config.backupsDir, `record-shop-${stamp}.db`)
|
||
|
|
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) }
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: `/api/export` returns raw rows (snake_case, internal ids) — intentional: it's a backup artifact, not a UI payload. Tests assert shape only.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register in `server/src/app.ts`** — import + `await registerDataRoutes(app)` after `registerStatsRoutes(app)`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run server/test/data.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (163 + 4 new = 167).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: json export and sqlite backup endpoints"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Web — types + api client additions
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `web/src/types.ts`, `web/src/api.ts`
|
||
|
|
- Test: `web/test/api.test.ts` (new, thin — URL/shape checks via fetch stub)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add types to `web/src/types.ts`** (append):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Modify `web/src/api.ts`** — extend the imports (`type { …, Track is not needed here }`, add `AlbumTracks, Loan, LoansResponse, Stats, BackupFile`) and append methods to the `api` object:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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'),
|
||
|
|
```
|
||
|
|
|
||
|
|
Also change `getItem`'s return type: `request<Item & { matchedAlbum: MatchedAlbum | null }>` (or add a `MatchedAlbum` import and use `ItemDetail` alias — define `export interface ItemDetail extends Item { matchedAlbum: MatchedAlbum | null }` in types.ts and use it in api.ts).
|
||
|
|
|
||
|
|
Also `listCollection` params gain `onLoan?: string`:
|
||
|
|
```ts
|
||
|
|
if (params.onLoan) usp.set('onLoan', params.onLoan)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write `web/test/api.test.ts`** (thin — stub fetch, assert URLs and error mapping):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||
|
|
import { api, ApiError } from '../src/api.js'
|
||
|
|
|
||
|
|
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.mockReturnValue(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.mockReturnValue(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')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/api.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (167 + 3 new = 170). Existing page tests that mock the api module keep working (spread of actual).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: web api client additions for wave 1"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 9: Player context + reducer + audio element
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `web/src/player/PlayerContext.tsx`
|
||
|
|
- Test: `web/test/player.test.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `web/test/player.test.tsx`**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { describe, it, expect, vi } 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 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 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())
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/player.test.tsx`
|
||
|
|
Expected: FAIL — module not found.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `web/src/player/PlayerContext.tsx`** — full file:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { createContext, useContext, useEffect, useReducer, useCallback, 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
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `useRef` to the react import. jsdom caveat: `HTMLMediaElement.play` is not implemented — `vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()` in the test file's top-level `beforeEach` (the scanner test already uses this pattern); also mock `pause`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/player.test.tsx && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (170 + 5 new = 175).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: global player context with album queue and audio element"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 10: MiniBar + expanded player, wired into the shell
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `web/src/player/MiniBar.tsx`
|
||
|
|
- Modify: `web/src/App.tsx` (wrap routes with PlayerProvider inside AuthProvider; render MiniBar inside the protected shell)
|
||
|
|
- Test: `web/test/minibar.test.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `web/test/minibar.test.tsx`**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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/i })).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())
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/minibar.test.tsx`
|
||
|
|
Expected: FAIL — MiniBar not found.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `web/src/player/MiniBar.tsx`** — full file:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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 gap-2">
|
||
|
|
<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'}`}
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(A seek bar is deferred: the audio element's seek needs ref plumbing between MiniBar and the provider's audio element. The spec's mini-bar lists seek — note in the task report if omitted, or implement via `usePlayer()` gaining `seek(t: number)` + a `currentTimeRef` — coordinator decision: seek lands with the expanded player in a follow-up fix if the simple version feels incomplete. Prefer shipping the above and adding seek during review if trivial.)
|
||
|
|
|
||
|
|
- [ ] **Step 4: Modify `web/src/App.tsx`** — wrap with PlayerProvider and render MiniBar:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { PlayerProvider } from './player/PlayerContext.js'
|
||
|
|
import MiniBar from './player/MiniBar.js'
|
||
|
|
```
|
||
|
|
- Outermost: `<AuthProvider>...` stays; inside it wrap the whole `<BrowserRouter>` content with `<PlayerProvider>`; render `<MiniBar />` inside the protected layout route element, after `<Shell />`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<Route
|
||
|
|
element={
|
||
|
|
<Gate>
|
||
|
|
<PlayerProvider>
|
||
|
|
<Shell />
|
||
|
|
<MiniBar />
|
||
|
|
</PlayerProvider>
|
||
|
|
</Gate>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
```
|
||
|
|
(The audio element lives in PlayerProvider; rendering MiniBar inside the same provider gives it context access. Keep `<audio>` in the provider — it renders nothing visible.)
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/minibar.test.tsx && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (175 + 3 new = 178). Existing router tests render App — PlayerProvider mounts an `<audio>` element in jsdom (harmless); MiniBar hidden without a queue.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: mini player bar with expandable track list"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 11: Item page — Play, last played, loans
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `web/src/pages/ItemPage.tsx`
|
||
|
|
- Test: `web/test/item.test.tsx` (append)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append failing tests to `web/test/item.test.tsx`**
|
||
|
|
|
||
|
|
The file's existing mocks: api module with getItem/setRip/setMatch/deleteItem/searchAlbums. Add getAlbumTracks/markPlayed/lendItem/getLoans/returnLoan to the override list; `getItem` fixture gains `matchedAlbum`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import type { Item, MatchedAlbum } from '../src/types.js'
|
||
|
|
|
||
|
|
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
|
||
|
|
const rippedItem: Item = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||
|
|
```
|
||
|
|
|
||
|
|
(Adjust the base `item` fixture: it must now carry `matchedAlbum: null` so existing tests keep passing; the default `getItem` mock resolves the base item.)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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 () => {
|
||
|
|
vi.mocked(api.getItem).mockResolvedValue(item as never) // not ripped, matchedAlbum null
|
||
|
|
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).mockResolvedValue(rippedItem 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'))
|
||
|
|
await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())
|
||
|
|
|
||
|
|
vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
|
||
|
|
vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem } as never)
|
||
|
|
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
|
||
|
|
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
(The lend flow's second phase needs the item's loan state — the page re-fetches the item after lend (`getItem` again) and shows 'Out to Bob…' + 'Mark returned' when `loan` present. See implementation: the detail response gains `loan: Loan | null` — ADD to the backend detail route in this task: `loan: activeLoanFor(db, row.id)` where `activeLoanFor` queries `loans WHERE item_id = ? AND returned_at IS NULL`. That is a small backend addition folded here: modify `server/src/routes/collectionRoutes.ts` detail route to include `loan`, and extend the backend test in `server/test/loans.test.ts` asserting `detail.json().loan` after lending.)
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/item.test.tsx`
|
||
|
|
Expected: FAIL — no Play/loan UI.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement**
|
||
|
|
|
||
|
|
Backend first — `server/src/routes/collectionRoutes.ts` detail route returns `loan` too:
|
||
|
|
```ts
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
(+ backend test assertion in loans.test.ts; type `ItemDetail` in web/src/types.ts gains `loan: { id: number; borrower: string; lentAt: string } | null`.)
|
||
|
|
|
||
|
|
Frontend — in `web/src/pages/ItemPage.tsx`:
|
||
|
|
- Type the item state as `ItemDetail | null`
|
||
|
|
- After the rip banner, before the toggle buttons:
|
||
|
|
```tsx
|
||
|
|
{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>
|
||
|
|
)}
|
||
|
|
```
|
||
|
|
- `load` from `usePlayer()`; `timeAgo(iso)` — small local helper (minutes/hours/days/months/years, 'just now' < 60s); define in the file.
|
||
|
|
- Loan section (after re-match section):
|
||
|
|
```tsx
|
||
|
|
<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())}
|
||
|
|
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()
|
||
|
|
if (!item) return
|
||
|
|
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
|
||
|
|
void api.lendItem(item.id, borrower).then(() => refetch())
|
||
|
|
}}
|
||
|
|
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>
|
||
|
|
```
|
||
|
|
- `refetch()` = re-run `api.getItem(Number(id)).then(setItem)` — extract the load into a `refetch` callback used by the mount effect too.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/item.test.tsx server/test/loans.test.ts && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (178 + 4 new = 182).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: play button, last played, loan lend/return on item page"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 12: Library links + On-loan chip; Queue page
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `web/src/pages/LibraryPage.tsx`, `web/src/App.tsx`
|
||
|
|
- Create: `web/src/pages/QueuePage.tsx`
|
||
|
|
- Test: `web/test/library.test.tsx` (append), `web/test/queue.test.tsx` (new)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append failing tests to `web/test/library.test.tsx`**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
it('header links to stats and queue', async () => {
|
||
|
|
renderLibrary()
|
||
|
|
await waitFor(() => expect(screen.getByRole('link', { name: /stats/i })).toHaveProperty('href'))
|
||
|
|
expect(screen.getByRole('link', { name: /queue/i })).toBeTruthy()
|
||
|
|
})
|
||
|
|
|
||
|
|
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() }))
|
||
|
|
)
|
||
|
|
})
|
||
|
|
```
|
||
|
|
(The plan's `toHaveProperty('href')` pattern from earlier tasks applies — use the file's existing link-assertion style.)
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write the failing test `web/test/queue.test.tsx`**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/library.test.tsx web/test/queue.test.tsx`
|
||
|
|
Expected: FAIL — no header links, no QueuePage.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Implement**
|
||
|
|
|
||
|
|
`web/src/pages/QueuePage.tsx` — full file:
|
||
|
|
```tsx
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`web/src/pages/LibraryPage.tsx` — two edits: header row above the search input:
|
||
|
|
```tsx
|
||
|
|
<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>
|
||
|
|
```
|
||
|
|
and a loan chip after the rip chips (RIP row): a third chip group rendered like the others — state `onLoan: boolean`, chip label 'On loan', toggling sets/clears, params include `...(onLoan ? { onLoan: 'true' } : {})`:
|
||
|
|
```tsx
|
||
|
|
<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>
|
||
|
|
```
|
||
|
|
(effect deps gain `onLoan`; params builder gains the onLoan spread.)
|
||
|
|
|
||
|
|
`web/src/App.tsx` — import QueuePage + route inside the protected block: `<Route path="/queue" element={<QueuePage />} />`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/library.test.tsx web/test/queue.test.tsx && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (182 + 4 new = 186).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: rip queue page, library stats/queue links and on-loan chip"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 13: Stats page
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `web/src/pages/StatsPage.tsx`
|
||
|
|
- Modify: `web/src/App.tsx` (route)
|
||
|
|
- Test: `web/test/stats.test.tsx` (new)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test `web/test/stats.test.tsx`**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
|
import { render, screen, waitFor } 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.findByText('4')).toBeTruthy()
|
||
|
|
expect(screen.getByText(/25% ripped/i)).toBeTruthy()
|
||
|
|
expect(screen.getByText(/1 on loan/i)).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()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/stats.test.tsx`
|
||
|
|
Expected: FAIL — module not found.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `web/src/pages/StatsPage.tsx`** — full file:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register the route in `web/src/App.tsx`** — import StatsPage + `<Route path="/stats" element={<StatsPage />} />` in the protected block.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/stats.test.tsx && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (186 + 3 new = 189).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: stats page with css bar charts and rip-ratio donut"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 14: Settings — Data section (export + backups)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `web/src/pages/SettingsPage.tsx`
|
||
|
|
- Test: `web/test/settings.test.tsx` (append)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append failing tests to `web/test/settings.test.tsx`**
|
||
|
|
|
||
|
|
(Extend the api mock override list with triggerBackup/getBackups. The file's fetch stub pattern for AuthProvider stays.)
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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())
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
(Use the file's existing link-assertion style if `toHaveProperty` clashes.)
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/settings.test.tsx`
|
||
|
|
Expected: FAIL — no Data section.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement — add to `web/src/pages/SettingsPage.tsx`** (after the Library sync Section, before Users):
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<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>
|
||
|
|
<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>
|
||
|
|
{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>
|
||
|
|
```
|
||
|
|
|
||
|
|
State: `const [backups, setBackups] = useState<BackupFile[] | null>(null)` (+ type import) and load on mount for admins:
|
||
|
|
```ts
|
||
|
|
useEffect(() => {
|
||
|
|
if (user?.isAdmin) {
|
||
|
|
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
|
||
|
|
}
|
||
|
|
}, [user])
|
||
|
|
```
|
||
|
|
(The existing admin listUsers effect can be extended instead of a second effect — implementer's choice, one effect is fine.)
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `npx vitest run web/test/settings.test.tsx && npm test && npm run typecheck`
|
||
|
|
Expected: ALL PASS (189 + 2 new = 191).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A && git commit -m "feat: settings data section with export and backups"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 15: Full verification + deploy note
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: none (verification task)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Full suite + typecheck + build**
|
||
|
|
|
||
|
|
Run: `npm test && npm run typecheck && npm run build`
|
||
|
|
Expected: 191 tests pass (82 backend baseline + 109 across wave-1 backend/frontend), typecheck clean, both dists built.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify the migration on the deployed instance's data (dry run)**
|
||
|
|
|
||
|
|
The deployed instance at `/home/samu/record-shop/data/record-shop.db` has real data (if the user has been using it) and runs the pre-v2 schema. Before restarting it, copy the DB and open the copy:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cp data/record-shop.db /tmp/opencode/rs-migrate-test.db
|
||
|
|
node -e "
|
||
|
|
import Database from 'better-sqlite3';
|
||
|
|
import { migrateUpgrades } from './server/dist/db.js';
|
||
|
|
const db = new Database('/tmp/opencode/rs-migrate-test.db');
|
||
|
|
migrateUpgrades(db);
|
||
|
|
console.log('version:', db.prepare(\"SELECT value FROM app_meta WHERE key='schema_version'\").get());
|
||
|
|
console.log('cols:', db.prepare('PRAGMA table_info(digital_albums)').all().map(c => c.name).join(','));
|
||
|
|
"
|
||
|
|
```
|
||
|
|
Expected: `version: {value: '2'}` and `last_played_at` present, existing rows intact. Delete the test copy afterwards.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Restart the deployed instance**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
pkill -f "node /home/samu/record-shop/server/dist/index.js"
|
||
|
|
sleep 1
|
||
|
|
npm run build
|
||
|
|
setsid nohup node /home/samu/record-shop/server/dist/index.js > /home/samu/record-shop/data/server.log 2>&1 < /dev/null &
|
||
|
|
sleep 2
|
||
|
|
curl -s http://localhost:3000/api/health
|
||
|
|
curl -s http://localhost:3000/ | grep -o '<title>[^<]*</title>'
|
||
|
|
```
|
||
|
|
Expected: `{"ok":true}` + SPA title; migration runs on boot against the real data.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Manual device checklist additions** (record, do not automate)
|
||
|
|
|
||
|
|
- Play an album on the phone (audio codec support, seek, auto-advance)
|
||
|
|
- Mini-bar persists across tab navigation
|
||
|
|
- Lend/return from the item page
|
||
|
|
- Export download on the phone (files app)
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit (if anything changed) or close out**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git status --short # should be clean; the deploy artifacts are gitignored
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API additions summary (reference)
|
||
|
|
|
||
|
|
| Method | Path | Notes |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| GET | `/api/stream/:songId` | Range passthrough, 502 `stream_unavailable` |
|
||
|
|
| GET | `/api/album/:subsonicId/tracks` | `{album:{id,title,artist}, tracks:[{id,title,duration,track}]}` |
|
||
|
|
| POST | `/api/album/:subsonicId/played` | stamps `last_played_at` (404 unknown) |
|
||
|
|
| GET | `/api/stats` | totals + ripRatio + formats/decades/genres/artists + addedByMonth |
|
||
|
|
| GET | `/api/export` | per-user JSON backup artifact (no secrets) |
|
||
|
|
| POST | `/api/backup` | admin; prunes to 7 |
|
||
|
|
| GET | `/api/backups` | admin; file list |
|
||
|
|
| POST | `/api/collection/:id/loan` | 400 empty borrower, 409 `already_on_loan` |
|
||
|
|
| GET | `/api/loans` | `{active, history}` |
|
||
|
|
| POST | `/api/loans/:id/return` | 404 when missing/returned |
|
||
|
|
| GET | `/api/collection/:id` | + `matchedAlbum`, + `loan` |
|
||
|
|
| GET | `/api/collection?onLoan=` | loan-state filter |
|
||
|
|
|
||
|
|
**Detail response additions:** `matchedAlbum: {id, subsonicId, lastPlayedAt} | null`, `loan: {id, borrower, lentAt} | null`.
|