From 52b2cbd6c5c3ed4e63e252273e13aae2d09baa52 Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 14:32:57 +0200 Subject: [PATCH] docs: backend implementation plan (18 tasks, TDD) --- SPEC.md | 0 .../plans/2026-08-29-record-shop-backend.md | 4104 +++++++++++++++++ 2 files changed, 4104 insertions(+) create mode 100644 SPEC.md create mode 100644 docs/superpowers/plans/2026-08-29-record-shop-backend.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/superpowers/plans/2026-08-29-record-shop-backend.md b/docs/superpowers/plans/2026-08-29-record-shop-backend.md new file mode 100644 index 0000000..e3b3138 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-record-shop-backend.md @@ -0,0 +1,4104 @@ +# record-shop Backend Implementation Plan + +> **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:** Build the complete record-shop backend: Fastify REST API with auth, per-user Discogs/Subsonic integrations, barcode + text lookup, collection management, rip-status matching, library sync, artwork caching — deployable as a single Docker container. + +**Architecture:** Single Node.js process. Fastify serves `/api/*` and (Task 17) the static SPA. SQLite (better-sqlite3, WAL) at `/data/record-shop.db`. All Discogs traffic flows through a paced serial queue (60 req/min limit). Subsonic library is cached per user in `digital_albums`. Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`. + +**Tech Stack:** TypeScript (ESM, NodeNext), Fastify 5, better-sqlite3, argon2, Vitest. Node >= 20 (verify with `node --version`; if lower, install Node 22 LTS first). + +**Scope note:** This is plan 1 of 2. The web frontend is a separate plan written after this one lands. This plan ends with a deployable container verified by `docker compose up`. + +--- + +## File structure (plan 1) + +``` +record-shop/ +├── package.json # single package: server deps, scripts +├── tsconfig.json # base TS config +├── vitest.config.ts +├── Dockerfile +├── docker-compose.yml +├── .dockerignore +├── .gitignore +├── server/ +│ ├── tsconfig.json # src + test, noEmit (typecheck/tests) +│ ├── tsconfig.build.json # src only, outDir dist +│ ├── src/ +│ │ ├── index.ts # entrypoint: loadConfig, openDatabase, listen +│ │ ├── app.ts # buildApp(): plugin + route registration +│ │ ├── config.ts # data dir, paths, session secret +│ │ ├── db.ts # openDatabase, migrate +│ │ ├── auth.ts # passwords, sessions, requireAuth/requireAdmin +│ │ ├── queue.ts # SerialQueue with min interval +│ │ ├── discogs.ts # DiscogsClient + API mappers + errors +│ │ ├── subsonic.ts # SubsonicClient + errors +│ │ ├── artwork.ts # disk cache for Discogs images +│ │ ├── matcher.ts # normalize, isConfidentMatch, candidateAlbums +│ │ ├── ripstatus.ts # resolveRipStatus +│ │ ├── sync.ts # SyncManager (background library sync) +│ │ └── routes/ +│ │ ├── authRoutes.ts # setup, login, logout, me, user admin +│ │ ├── settingsRoutes.ts # get/put per-user settings +│ │ ├── lookupRoutes.ts # barcode/search/release lookup +│ │ ├── collectionRoutes.ts +│ │ └── libraryRoutes.ts # sync status/trigger, album search +│ └── test/ +│ ├── helpers.ts # buildTestApp, cookie utils +│ ├── fixtures.ts # Discogs/Subsonic response fixtures +│ ├── app.test.ts +│ ├── db.test.ts +│ ├── config.test.ts +│ ├── auth.test.ts +│ ├── authRoutes.test.ts +│ ├── settings.test.ts +│ ├── queue.test.ts +│ ├── discogs.test.ts +│ ├── subsonic.test.ts +│ ├── artwork.test.ts +│ ├── matcher.test.ts +│ ├── ripstatus.test.ts +│ ├── lookup.test.ts +│ ├── collection.test.ts +│ └── library.test.ts +``` + +--- + +### Task 1: Project scaffold — package, TS, Vitest, health endpoint + +**Files:** +- Create: `package.json`, `tsconfig.json`, `server/tsconfig.json`, `server/tsconfig.build.json`, `vitest.config.ts`, `.gitignore`, `server/src/app.ts`, `server/src/index.ts`, `server/src/db.ts`, `server/test/app.test.ts` + +- [ ] **Step 1: Verify Node version** + +Run: `node --version` +Expected: `v20.x` or higher. If lower, install Node 22 LTS before continuing. + +- [ ] **Step 2: Create `package.json`** + +```json +{ + "name": "record-shop", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "dev:server": "tsx watch server/src/index.ts", + "dev": "npm run dev:server", + "test": "vitest run", + "test:watch": "vitest", + "build:server": "tsc -p server/tsconfig.build.json", + "build": "npm run build:server", + "start": "node server/dist/index.js", + "typecheck": "tsc -p server/tsconfig.json --noEmit" + }, + "dependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/static": "^8.0.0", + "argon2": "^0.41.1", + "better-sqlite3": "^11.3.0", + "fastify": "^5.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.5.0", + "tsx": "^4.16.0", + "typescript": "^5.5.4", + "vitest": "^3.0.0" + } +} +``` + +(Plan 2 adds `concurrently`, Vite, React, Tailwind to this file.) + +- [ ] **Step 3: Create `tsconfig.json` (base)** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["node"] + } +} +``` + +- [ ] **Step 4: Create `server/tsconfig.json`** + +```json +{ + "extends": "../tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*", "test/**/*"] +} +``` + +- [ ] **Step 5: Create `server/tsconfig.build.json`** + +```json +{ + "extends": "../tsconfig.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 6: Create `vitest.config.ts`** + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['server/test/**/*.test.ts'], + }, +}) +``` + +- [ ] **Step 7: Create `.gitignore`** + +``` +node_modules/ +server/dist/ +web/dist/ +data/ +*.log +.DS_Store +``` + +- [ ] **Step 8: Write the failing test `server/test/app.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildApp } from '../src/app.js' +import { openDatabase } from '../src/db.js' +import type { Config } from '../src/config.js' + +const config: Config = { + dataDir: ':memory:', + artworkDir: ':memory:', + dbPath: ':memory:', + port: 0, + sessionSecret: 'test-secret-test-secret-test-secret-1234', +} + +describe('GET /api/health', () => { + it('returns ok', async () => { + const db = openDatabase(':memory:') + const app = await buildApp({ db, config }) + const res = await app.inject({ method: 'GET', url: '/api/health' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + await app.close() + }) +}) +``` + +- [ ] **Step 9: Run test to verify it fails** + +Run: `npx vitest run server/test/app.test.ts` +Expected: FAIL — cannot resolve `../src/app.js` / `../src/config.js`. + +- [ ] **Step 10: Create minimal `server/src/db.ts`** + +```ts +import Database from 'better-sqlite3' + +export type DB = Database.Database + +export function openDatabase(path: string): DB { + const db = new Database(path) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + return db +} +``` + +- [ ] **Step 11: Create `server/src/config.ts`** (minimal now; full behavior tested in Task 2) + +```ts +export interface Config { + dataDir: string + artworkDir: string + dbPath: string + port: number + sessionSecret: string +} +``` + +- [ ] **Step 12: Create minimal `server/src/app.ts`** + +```ts +import Fastify, { FastifyInstance } from 'fastify' +import Database from 'better-sqlite3' +import type { Config } from './config.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Database.Database + config: Config + fetchImpl: typeof fetch + } +} + +export interface AppOptions { + db: Database.Database + config: Config + fetchImpl?: typeof fetch +} + +export async function buildApp(opts: AppOptions): Promise { + const app = Fastify({ logger: false }) + app.decorate('db', opts.db) + app.decorate('config', opts.config) + app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + app.get('/api/health', async () => ({ ok: true })) + return app +} +``` + +- [ ] **Step 13: Create `server/src/index.ts`** (placeholder entrypoint; wired fully in Task 3) + +```ts +import { buildApp } from './app.js' +import { openDatabase } from './db.js' +import type { Config } from './config.js' + +const config: Config = { + dataDir: process.env.DATA_DIR ?? 'data', + artworkDir: (process.env.DATA_DIR ?? 'data') + '/artwork-cache', + dbPath: (process.env.DATA_DIR ?? 'data') + '/record-shop.db', + port: Number(process.env.PORT ?? 3000), + sessionSecret: 'placeholder-replaced-in-task-3', +} + +const db = openDatabase(config.dbPath) +const app = await buildApp({ db, config }) +await app.listen({ port: config.port, host: '0.0.0.0' }) +``` + +- [ ] **Step 14: Install dependencies, run tests, commit** + +Run: `npm install` +Then: `npm test` +Expected: PASS (1 test). +Then commit: + +```bash +git add -A && git commit -m "chore: scaffold node/ts/vitest project with health endpoint" +``` + +--- + +### Task 2: Config module + database schema + +**Files:** +- Modify: `server/src/config.ts` (replace minimal version), `server/src/db.ts` (add `migrate`) +- Test: `server/test/config.test.ts`, `server/test/db.test.ts` (both new) + +- [ ] **Step 1: Write the failing test `server/test/config.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { loadConfig } from '../src/config.js' + +function tempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'rs-config-')) +} + +describe('loadConfig', () => { + it('creates data + artwork dirs and returns paths', () => { + const dir = tempDir() + try { + const cfg = loadConfig({ DATA_DIR: dir }) + expect(cfg.dataDir).toBe(dir) + expect(cfg.dbPath).toBe(path.join(dir, 'record-shop.db')) + expect(cfg.port).toBe(3000) + expect(existsSync(path.join(dir, 'artwork-cache'))).toBe(true) + expect(cfg.sessionSecret).toMatch(/^[0-9a-f]{64}$/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('persists and reuses the session secret', () => { + const dir = tempDir() + try { + const a = loadConfig({ DATA_DIR: dir }) + const b = loadConfig({ DATA_DIR: dir }) + expect(a.sessionSecret).toBe(b.sessionSecret) + expect(readFileSync(path.join(dir, 'session-secret'), 'utf8')).toBe(a.sessionSecret) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('honours PORT', () => { + const dir = tempDir() + try { + expect(loadConfig({ DATA_DIR: dir, PORT: '8080' }).port).toBe(8080) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/config.test.ts` +Expected: FAIL — `loadConfig` is not exported. + +- [ ] **Step 3: Replace `server/src/config.ts`** + +```ts +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +export interface Config { + dataDir: string + artworkDir: string + dbPath: string + port: number + sessionSecret: string +} + +export function loadConfig(env: Record = process.env): Config { + const dataDir = env.DATA_DIR ?? path.resolve('data') + mkdirSync(dataDir, { recursive: true }) + const artworkDir = path.join(dataDir, 'artwork-cache') + mkdirSync(artworkDir, { recursive: true }) + return { + dataDir, + artworkDir, + dbPath: path.join(dataDir, 'record-shop.db'), + port: Number(env.PORT ?? 3000), + sessionSecret: getOrCreateSecret(dataDir), + } +} + +function getOrCreateSecret(dataDir: string): string { + const secretPath = path.join(dataDir, 'session-secret') + try { + const existing = readFileSync(secretPath, 'utf8').trim() + if (existing) return existing + } catch { + // first boot — create below + } + const secret = crypto.randomBytes(32).toString('hex') + writeFileSync(secretPath, secret, { mode: 0o600 }) + return secret +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run server/test/config.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Write the failing test `server/test/db.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' + +describe('openDatabase', () => { + it('creates the full schema', () => { + const db = openDatabase(':memory:') + const tables = ( + db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as { + name: string + }[] + ).map((r) => r.name) + for (const t of [ + 'users', + 'sessions', + 'settings', + 'collection_items', + 'digital_albums', + 'match_links', + 'app_meta', + ]) { + expect(tables).toContain(t) + } + }) + + it('enforces unique (user_id, discogs_release_id)', () => { + const db = openDatabase(':memory:') + db.prepare( + "INSERT INTO users (username, password_hash, is_admin) VALUES ('sam', 'x', 1)" + ).run() + const insert = db.prepare( + `INSERT INTO collection_items (user_id, discogs_release_id, title, artist) + VALUES (1, 100, 'Album', 'Artist')` + ) + insert.run() + expect(() => insert.run()).toThrow() + }) +}) +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npx vitest run server/test/db.test.ts` +Expected: FAIL — no such table: users. + +- [ ] **Step 7: Add `migrate` to `server/src/db.ts`** — the full file becomes: + +```ts +import Database from 'better-sqlite3' + +export type DB = Database.Database + +export function openDatabase(path: string): DB { + const db = new Database(path) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + migrate(db) + return db +} + +export function migrate(db: DB): void { + db.exec(` + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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) + ); + `) +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx vitest run server/test/db.test.ts && npm test` +Expected: PASS (2 new + 1 scaffold). + +- [ ] **Step 9: Commit** + +```bash +git add -A && git commit -m "feat: config loading and sqlite schema" +``` + +--- + +### Task 3: Real entrypoint + +**Files:** +- Modify: `server/src/index.ts` (replace placeholder) + +- [ ] **Step 1: Replace `server/src/index.ts`** + +```ts +import { buildApp } from './app.js' +import { openDatabase } from './db.js' +import { loadConfig } from './config.js' + +const config = loadConfig() +const db = openDatabase(config.dbPath) +const app = await buildApp({ db, config }) +await app.listen({ port: config.port, host: '0.0.0.0' }) +``` + +- [ ] **Step 2: Verify manually** + +Run: `npm run dev:server &` then `curl -s http://localhost:3000/api/health` — expect `{"ok":true}`. Then `kill %1`. + +- [ ] **Step 3: Run tests + typecheck** + +Run: `npm test && npm run typecheck` +Expected: PASS, no type errors. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: real entrypoint using loadConfig" +``` + +--- + +### Task 4: Auth core — argon2 passwords + sessions + +**Files:** +- Create: `server/src/auth.ts` +- Test: `server/test/auth.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/auth.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' +import { + hashPassword, + verifyPassword, + createUser, + createSession, + getUserBySession, + deleteSession, +} from '../src/auth.js' + +describe('passwords', () => { + it('hashes and verifies', async () => { + const hash = await hashPassword('correct horse battery staple') + expect(hash).not.toContain('correct') + expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true) + expect(await verifyPassword(hash, 'wrong')).toBe(false) + }) +}) + +describe('sessions', () => { + it('creates a user and resolves a valid session, rejects expired', async () => { + const db = openDatabase(':memory:') + const user = createUser(db, 'sam', await hashPassword('password123'), true) + expect(user.is_admin).toBe(1) + + const token = await createSession(db, user.id) + const resolved = getUserBySession(db, token, () => Date.now()) + expect(resolved?.username).toBe('sam') + + const expiredToken = await createSession(db, user.id) + db.prepare('UPDATE sessions SET expires_at = ? WHERE token = ?').run( + '2000-01-01T00:00:00.000Z', + expiredToken + ) + expect(getUserBySession(db, expiredToken, () => Date.now())).toBeNull() + expect( + db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number } + ).toEqual({ n: 1 }) + + deleteSession(db, token) + expect(getUserBySession(db, token, () => Date.now())).toBeNull() + }) + + it('rejects duplicate usernames', async () => { + const db = openDatabase(':memory:') + createUser(db, 'sam', 'hash1', false) + expect(() => createUser(db, 'sam', 'hash2', false)).toThrow() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/auth.test.ts` +Expected: FAIL — module `../src/auth.js` not found. + +- [ ] **Step 3: Implement `server/src/auth.ts`** + +```ts +import crypto from 'node:crypto' +import argon2 from 'argon2' +import type { DB } from './db.js' + +export interface UserRow { + id: number + username: string + password_hash: string + is_admin: number + created_at: string +} + +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +export function hashPassword(password: string): Promise { + return argon2.hash(password) +} + +export function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password) +} + +export function createUser(db: DB, username: string, passwordHash: string, isAdmin: boolean): UserRow { + const info = db + .prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)') + .run(username, passwordHash, isAdmin ? 1 : 0) + return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as UserRow +} + +export function getUserByUsername(db: DB, username: string): UserRow | undefined { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined +} + +export function getUserById(db: DB, id: number): UserRow | undefined { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined +} + +export function createSession(db: DB, userId: number): string { + const token = crypto.randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString() + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + userId, + expiresAt + ) + return token +} + +export function getUserBySession(db: DB, token: string, now: () => number = Date.now): UserRow | null { + const row = db + .prepare( + `SELECT u.*, s.expires_at FROM sessions s + JOIN users u ON u.id = s.user_id WHERE s.token = ?` + ) + .get(token) as (UserRow & { expires_at: string }) | undefined + if (!row) return null + if (new Date(row.expires_at).getTime() < now()) { + deleteSession(db, token) + return null + } + return row +} + +export function deleteSession(db: DB, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run server/test/auth.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: argon2 passwords and sqlite-backed sessions" +``` + +--- + +### Task 5: Auth routes — setup, login, logout, me + +**Files:** +- Create: `server/src/routes/authRoutes.ts`, `server/test/helpers.ts` +- Test: `server/test/authRoutes.test.ts` +- Modify: `server/src/app.ts` + +- [ ] **Step 1: Create `server/test/helpers.ts`** + +```ts +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { openDatabase, type DB } from '../src/db.js' +import { buildApp } from '../src/app.js' +import type { Config } from '../src/config.js' +import type { FastifyInstance } from 'fastify' + +export function testConfig(): Config { + // artworkDir must be a real writable directory (cached images land there) + const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-')) + return { + dataDir: ':memory:', + artworkDir, + dbPath: ':memory:', + port: 0, + sessionSecret: 'test-secret-test-secret-test-secret-1234', + } +} + +export async function buildTestApp(fetchImpl?: typeof fetch): Promise { + const db = openDatabase(':memory:') + return buildApp({ db, config: testConfig(), fetchImpl }) +} + +export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise { + return buildApp({ db, config: testConfig(), fetchImpl }) +} + +/** Runs /api/setup to create admin 'admin' / 'adminpass123'. Returns session cookie. */ +export async function setupAdmin(app: FastifyInstance): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'admin', password: 'adminpass123' }, + }) + if (res.statusCode !== 200) throw new Error(`setup failed: ${res.body}`) + return getCookie(res) +} + +/** Creates and logs in a non-admin user via /api/users + /api/login. Returns cookie. */ +export async function loginAs( + app: FastifyInstance, + adminCookie: string, + username: string, + password: string +): Promise { + const created = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(adminCookie), + payload: { username, password }, + }) + if (created.statusCode !== 200) throw new Error(`user create failed: ${created.body}`) + const login = await app.inject({ method: 'POST', url: '/api/login', payload: { username, password } }) + if (login.statusCode !== 200) throw new Error(`login failed: ${login.body}`) + return getCookie(login) +} + +export function getCookie(res: { cookies: { name: string; value: string }[] }): string { + const cookie = res.cookies.find((c) => c.name === 'rs_session') + if (!cookie) throw new Error('no rs_session cookie in response') + return cookie.value +} + +/** inject option spread for an authenticated request: `app.inject({ ..., ...auth(token) })` */ +export function auth(sessionToken: string): { cookies: Record } { + return { cookies: { rs_session: sessionToken } } +} +``` + +- [ ] **Step 2: Write the failing test `server/test/authRoutes.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js' + +describe('auth routes', () => { + it('setup creates admin when no users exist, then is closed', async () => { + const app = await buildTestApp() + const status = await app.inject({ method: 'GET', url: '/api/setup' }) + expect(status.json()).toEqual({ needed: true }) + + const cookie = await setupAdmin(app) + + const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(me.statusCode).toBe(200) + expect(me.json()).toEqual({ user: { id: 1, username: 'admin', isAdmin: true } }) + + const again = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'x', password: 'password123' }, + }) + expect(again.statusCode).toBe(403) + await app.close() + }) + + it('setup validates input', async () => { + const app = await buildTestApp() + const bad = await app.inject({ + method: 'POST', + url: '/api/setup', + payload: { username: 'ab', password: 'short' }, + }) + expect(bad.statusCode).toBe(400) + await app.close() + }) + + it('login and logout', async () => { + const app = await buildTestApp() + await setupAdmin(app) + const login = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'admin', password: 'adminpass123' }, + }) + expect(login.statusCode).toBe(200) + const cookie = getCookie(login) + + const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(me.statusCode).toBe(200) + + await app.inject({ method: 'POST', url: '/api/logout', ...auth(cookie) }) + const after = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) }) + expect(after.statusCode).toBe(401) + await app.close() + }) + + it('login rejects wrong password', async () => { + const app = await buildTestApp() + await setupAdmin(app) + const res = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'admin', password: 'wrongpass123' }, + }) + expect(res.statusCode).toBe(401) + await app.close() + }) + + it('protected route requires auth', async () => { + const app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/api/me' }) + expect(res.statusCode).toBe(401) + await app.close() + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run server/test/authRoutes.test.ts` +Expected: FAIL — 404s. + +- [ ] **Step 4: Implement `server/src/routes/authRoutes.ts`** + +```ts +import { FastifyInstance } from 'fastify' +import { + hashPassword, + verifyPassword, + createUser, + getUserByUsername, + createSession, + getUserBySession, + deleteSession, + type UserRow, +} from '../auth.js' + +export const COOKIE_NAME = 'rs_session' + +export interface PublicUser { + id: number + username: string + isAdmin: boolean +} + +export function toPublicUser(u: { id: number; username: string; is_admin: number }): PublicUser { + return { id: u.id, username: u.username, isAdmin: u.is_admin === 1 } +} + +function validateCredentials(username: unknown, password: unknown): string | null { + if (typeof username !== 'string' || username.length < 3 || username.length > 40) { + return 'username must be 3-40 characters' + } + if (typeof password !== 'string' || password.length < 8) { + return 'password must be at least 8 characters' + } + return null +} + +export function cookieOpts() { + return { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + maxAge: 30 * 24 * 60 * 60, // seconds + } +} + +export async function requireAuth(request: any, reply: any): Promise { + const token = request.cookies[COOKIE_NAME] + if (token) { + const user = getUserBySession(request.server.db, token) + if (user) { + request.user = user + return + } + } + await reply.code(401).send({ error: 'unauthorized' }) +} + +export async function requireAdmin(request: any, reply: any): Promise { + if (!request.user || request.user.is_admin !== 1) { + await reply.code(403).send({ error: 'forbidden' }) + } +} + +export async function registerAuthRoutes(app: FastifyInstance): Promise { + app.get('/api/setup', async (request) => { + const count = ( + request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } + ).n + return { needed: count === 0 } + }) + + app.post('/api/setup', async (request, reply) => { + const count = ( + request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number } + ).n + if (count > 0) return reply.code(403).send({ error: 'setup_already_done' }) + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const invalid = validateCredentials(username, password) + if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid }) + const user = createUser(request.server.db, username as string, await hashPassword(password as string), true) + request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) + const token = createSession(request.server.db, user.id) + return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) + }) + + app.post('/api/login', async (request, reply) => { + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const user = typeof username === 'string' ? getUserByUsername(request.server.db, username) : undefined + if (!user || typeof password !== 'string' || !(await verifyPassword(user.password_hash, password))) { + return reply.code(401).send({ error: 'invalid_credentials' }) + } + const token = createSession(request.server.db, user.id) + return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) + }) + + app.post('/api/logout', async (request, reply) => { + const token = request.cookies[COOKIE_NAME] + if (token) deleteSession(request.server.db, token) + return reply.clearCookie(COOKIE_NAME, { path: '/' }).code(200).send({ ok: true }) + }) + + app.get('/api/me', { preHandler: [requireAuth] }, async (request) => { + return { user: toPublicUser(request.user as UserRow) } + }) +} +``` + +- [ ] **Step 5: Modify `server/src/app.ts`** — extend the module declaration and register the plugin. The full file becomes: + +```ts +import Fastify, { FastifyInstance } from 'fastify' +import Database from 'better-sqlite3' +import cookie from '@fastify/cookie' +import type { Config } from './config.js' +import { registerAuthRoutes } from './routes/authRoutes.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Database.Database + config: Config + fetchImpl: typeof fetch + } + interface FastifyRequest { + user?: import('./auth.js').UserRow + } +} + +export interface AppOptions { + db: Database.Database + config: Config + fetchImpl?: typeof fetch +} + +export async function buildApp(opts: AppOptions): Promise { + const app = Fastify({ logger: false }) + app.decorate('db', opts.db) + app.decorate('config', opts.config) + app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + + await app.register(cookie) + await registerAuthRoutes(app) + + app.get('/api/health', async () => ({ ok: true })) + return app +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run server/test/authRoutes.test.ts && npm test && npm run typecheck` +Expected: all PASS, no type errors. + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "feat: setup/login/logout/me auth routes" +``` + +--- + +### Task 6: User admin routes + +**Files:** +- Modify: `server/src/routes/authRoutes.ts` +- Test: `server/test/authRoutes.test.ts` (append) + +- [ ] **Step 1: Append failing tests to `server/test/authRoutes.test.ts`** + +```ts +import { loginAs } from './helpers.js' + +describe('user admin', () => { + async function adminApp() { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + return { app, cookie } + } + + it('admin creates a user and lists users', async () => { + const { app, cookie } = await adminApp() + const created = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + expect(created.statusCode).toBe(200) + expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false }) + + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) }) + expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob']) + await app.close() + }) + + it('non-admin cannot list or create users', async () => { + const { app, cookie } = await adminApp() + const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123') + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) }) + expect(list.statusCode).toBe(403) + const create = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(bobCookie), + payload: { username: 'eve', password: 'evepass123' }, + }) + expect(create.statusCode).toBe(403) + await app.close() + }) + + it('cannot delete self; deleting another user works and cascades settings', async () => { + const { app, cookie } = await adminApp() + await loginAs(app, cookie, 'bob', 'bobpass123') + const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) }) + expect(selfDelete.statusCode).toBe(400) + + const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) }) + expect(del.statusCode).toBe(200) + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) }) + expect(list.json().users).toHaveLength(1) + await app.close() + }) + + it('rejects duplicate username', async () => { + const { app, cookie } = await adminApp() + await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + const again = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'otherpass123' }, + }) + expect(again.statusCode).toBe(409) + await app.close() + }) +}) +``` + +Note: the new `import { loginAs } from './helpers.js'` goes at the top of the file, alongside the existing imports. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/authRoutes.test.ts` +Expected: FAIL — 404 on `/api/users`. + +- [ ] **Step 3: Add routes to `registerAuthRoutes` in `server/src/routes/authRoutes.ts`** (inside the function, at the end). Also extend the import from `../auth.js` to include `getUserById`: + +```ts +import { + hashPassword, + verifyPassword, + createUser, + getUserByUsername, + getUserById, + createSession, + getUserBySession, + deleteSession, + type UserRow, +} from '../auth.js' +``` + +```ts + app.get('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request) => { + const users = request.server.db.prepare('SELECT * FROM users ORDER BY id').all() as UserRow[] + return { users: users.map(toPublicUser) } + }) + + app.post('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const invalid = validateCredentials(username, password) + if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid }) + if (getUserByUsername(request.server.db, username as string)) { + return reply.code(409).send({ error: 'username_taken' }) + } + const user = createUser(request.server.db, username as string, await hashPassword(password as string), false) + request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) + return reply.code(200).send(toPublicUser(user)) + }) + + app.delete( + '/api/users/:id', + { preHandler: [requireAuth, requireAdmin] }, + async (request, reply) => { + const id = Number((request.params as { id: string }).id) + if (id === (request.user as UserRow).id) { + return reply.code(400).send({ error: 'cannot_delete_self' }) + } + if (!getUserById(request.server.db, id)) { + return reply.code(404).send({ error: 'not_found' }) + } + request.server.db.prepare('DELETE FROM users WHERE id = ?').run(id) + return reply.code(200).send({ ok: true }) + } + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run server/test/authRoutes.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: admin user management routes" +``` + +--- + +### Task 7: Settings routes + +**Files:** +- Create: `server/src/routes/settingsRoutes.ts` +- Test: `server/test/settings.test.ts` +- Modify: `server/src/app.ts`, `server/src/subsonic.ts` (created here with `ping`; `getAllAlbums` arrives in Task 10) + +- [ ] **Step 1: Write the failing test `server/test/settings.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, auth } from './helpers.js' + +function subsonicStubFetch(ok: boolean): typeof fetch { + return (async (url: any) => { + if (String(url).includes('/rest/ping')) { + const body = ok + ? { 'subsonic-response': { status: 'ok' } } + : { + 'subsonic-response': { + status: 'failed', + error: { code: 40, message: 'Wrong username or password.' }, + }, + } + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch +} + +describe('settings routes', () => { + it('starts empty and stores/clears tokens', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + + const empty = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) }) + expect(empty.json()).toEqual({ + hasDiscogsToken: false, + discogsTokenMasked: null, + subsonicUrl: null, + subsonicUsername: null, + hasSubsonicPassword: false, + }) + + const put = await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: 'abcdef0123456789' }, + }) + expect(put.statusCode).toBe(200) + expect(put.json()).toMatchObject({ hasDiscogsToken: true, discogsTokenMasked: '****56789' }) + + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: '' }, + }) + const cleared = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) }) + expect(cleared.json().hasDiscogsToken).toBe(false) + await app.close() + }) + + it('stores valid subsonic config after successful ping', async () => { + const app = await buildTestApp(subsonicStubFetch(true)) + const cookie = await setupAdmin(app) + const put = await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://navidrome.local', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + expect(put.statusCode).toBe(200) + expect(put.json()).toMatchObject({ + subsonicUrl: 'http://navidrome.local', + hasSubsonicPassword: true, + }) + await app.close() + }) + + it('rejects bad subsonic credentials with 400 subsonic_auth', async () => { + const app = await buildTestApp(subsonicStubFetch(false)) + const cookie = await setupAdmin(app) + const put = await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://navidrome.local', + subsonicUsername: 'sam', + subsonicPassword: 'wrong', + }, + }) + expect(put.statusCode).toBe(400) + expect(put.json()).toMatchObject({ error: 'subsonic_auth' }) + await app.close() + }) + + it('rejects unreachable subsonic with 400 subsonic_unreachable', async () => { + const failing = (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + const app = await buildTestApp(failing) + const cookie = await setupAdmin(app) + const put = await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://nope.invalid', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + expect(put.statusCode).toBe(400) + expect(put.json()).toMatchObject({ error: 'subsonic_unreachable' }) + await app.close() + }) + + it('requires auth', async () => { + const app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/api/settings' }) + expect(res.statusCode).toBe(401) + await app.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/settings.test.ts` +Expected: FAIL — 404 on `/api/settings`. + +- [ ] **Step 3: Create `server/src/subsonic.ts`** with `ping` (the `getAllAlbums` method is added in Task 10): + +```ts +import crypto from 'node:crypto' + +export interface SubsonicAlbum { + id: string + title: string + artist: string +} + +export class SubsonicError extends Error { + constructor( + public code: 'auth' | 'unreachable' | 'api', + message: string + ) { + super(message) + } +} + +export interface SubsonicClientOptions { + url: string + username: string + password: string + fetchImpl?: typeof fetch + clientName?: string +} + +export class SubsonicClient { + private base: string + private username: string + private password: string + private fetchImpl: typeof fetch + private clientName: string + + constructor(opts: SubsonicClientOptions) { + this.base = opts.url.replace(/\/+$/, '') + this.username = opts.username + this.password = opts.password + this.fetchImpl = opts.fetchImpl ?? fetch + this.clientName = opts.clientName ?? 'record-shop' + } + + private authParams(): Record { + const salt = crypto.randomBytes(8).toString('hex') + const token = crypto.createHash('md5').update(this.password + salt).digest('hex') + return { u: this.username, t: token, s: salt, v: '1.16.1', c: this.clientName, f: 'json' } + } + + private async request(endpoint: string, params: Record = {}): Promise { + const url = new URL(`${this.base}/rest/${endpoint}`) + const search = { ...this.authParams(), ...params } + for (const [k, v] of Object.entries(search)) url.searchParams.set(k, v) + let res: Response + try { + res = await this.fetchImpl(url.toString()) + } catch { + throw new SubsonicError('unreachable', `could not reach ${this.base}`) + } + if (!res.ok) throw new SubsonicError('unreachable', `HTTP ${res.status} from ${this.base}`) + const body = await res.json() + const envelope = body['subsonic-response'] + if (!envelope) throw new SubsonicError('api', 'malformed subsonic response') + if (envelope.status !== 'ok') { + const message: string = envelope.error?.message ?? 'subsonic request failed' + const code = envelope.error?.code === 40 || /credential|auth/i.test(message) ? 'auth' : 'api' + throw new SubsonicError(code, message) + } + return envelope + } + + async ping(): Promise { + await this.request('ping') + } +} +``` + +- [ ] **Step 4: Implement `server/src/routes/settingsRoutes.ts`** + +```ts +import { FastifyInstance } from 'fastify' +import { SubsonicClient } from '../subsonic.js' +import { requireAuth } from './authRoutes.js' +import type { DB } from '../db.js' + +export interface SettingsRow { + user_id: number + discogs_token: string | null + subsonic_url: string | null + subsonic_username: string | null + subsonic_password: string | null +} + +export function getSettings(db: DB, userId: number): SettingsRow { + const row = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as + | SettingsRow + | undefined + if (row) return row + db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(userId) + return db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as SettingsRow +} + +export function settingsView(s: SettingsRow) { + return { + hasDiscogsToken: !!s.discogs_token, + discogsTokenMasked: s.discogs_token ? `****${s.discogs_token.slice(-5)}` : null, + subsonicUrl: s.subsonic_url, + subsonicUsername: s.subsonic_username, + hasSubsonicPassword: !!s.subsonic_password, + } +} + +export function subsonicConfigComplete(s: SettingsRow): boolean { + return !!(s.subsonic_url && s.subsonic_username && s.subsonic_password) +} + +export async function registerSettingsRoutes(app: FastifyInstance): Promise { + app.get('/api/settings', { preHandler: [requireAuth] }, async (request) => { + const s = getSettings(request.server.db, (request.user as any).id) + return settingsView(s) + }) + + app.put('/api/settings', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const userId = (request.user as any).id + const s = getSettings(db, userId) + const body = (request.body ?? {}) as Record + + const next = { + discogs_token: body.discogsToken !== undefined ? body.discogsToken || null : s.discogs_token, + subsonic_url: body.subsonicUrl !== undefined ? body.subsonicUrl || null : s.subsonic_url, + subsonic_username: + body.subsonicUsername !== undefined ? body.subsonicUsername || null : s.subsonic_username, + subsonic_password: + body.subsonicPassword !== undefined ? body.subsonicPassword || null : s.subsonic_password, + } + + const candidate: SettingsRow = { ...s, ...next } + if (subsonicConfigComplete(candidate)) { + const client = new SubsonicClient({ + url: candidate.subsonic_url as string, + username: candidate.subsonic_username as string, + password: candidate.subsonic_password as string, + fetchImpl: request.server.fetchImpl, + }) + try { + await client.ping() + } catch (err: any) { + if (err.code === 'unreachable') { + return reply.code(400).send({ error: 'subsonic_unreachable', detail: err.message }) + } + return reply.code(400).send({ error: 'subsonic_auth', detail: err.message }) + } + } + + db.prepare( + `UPDATE settings SET discogs_token = ?, subsonic_url = ?, subsonic_username = ?, subsonic_password = ? + WHERE user_id = ?` + ).run( + next.discogs_token, + next.subsonic_url, + next.subsonic_username, + next.subsonic_password, + userId + ) + + return settingsView(getSettings(db, userId)) + }) +} +``` + +- [ ] **Step 5: Register in `server/src/app.ts`** — add import and one line: + +```ts +import { registerSettingsRoutes } from './routes/settingsRoutes.js' +``` + +```ts + await registerSettingsRoutes(app) +``` + +(place directly after `await registerAuthRoutes(app)`) + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run server/test/settings.test.ts && npm test && npm run typecheck` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "feat: per-user settings routes with subsonic validation" +``` + +--- + +### Task 8: Serial queue with pacing + +**Files:** +- Create: `server/src/queue.ts` +- Test: `server/test/queue.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/queue.test.ts`** + +```ts +import { describe, it, expect, vi } from 'vitest' +import { SerialQueue } from '../src/queue.js' + +describe('SerialQueue', () => { + it('runs tasks strictly in order even when they resolve out of order', async () => { + const q = new SerialQueue() + const order: number[] = [] + const slow = () => new Promise((r) => setTimeout(r, 20)).then(() => order.push(1)) + const fast = () => Promise.resolve().then(() => order.push(2)) + await Promise.all([q.run(slow), q.run(fast)]) + expect(order).toEqual([1, 2]) + }) + + it('continues after a failing task', async () => { + const q = new SerialQueue() + await expect( + q.run(async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + const result = await q.run(async () => 'ok') + expect(result).toBe('ok') + }) + + it('paces call starts at least minIntervalMs apart', async () => { + vi.useFakeTimers() + const q = new SerialQueue({ minIntervalMs: 1000 }) + const times: number[] = [] + const p1 = q.run(async () => { + times.push(Date.now()) + }) + const p2 = q.run(async () => { + times.push(Date.now()) + }) + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + await Promise.all([p1, p2]) + expect(times[1] - times[0]).toBeGreaterThanOrEqual(1000) + vi.useRealTimers() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/queue.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `server/src/queue.ts`** + +```ts +export interface SerialQueueOptions { + /** Minimum delay between task starts. 0 = unpaced. */ + minIntervalMs?: number +} + +export class SerialQueue { + private tail: Promise = Promise.resolve() + private lastStart = 0 + private minIntervalMs: number + + constructor(opts: SerialQueueOptions = {}) { + this.minIntervalMs = opts.minIntervalMs ?? 0 + } + + run(fn: () => Promise): Promise { + const result = this.tail.then(async () => { + if (this.minIntervalMs > 0) { + const wait = this.lastStart + this.minIntervalMs - Date.now() + if (wait > 0) await new Promise((r) => setTimeout(r, wait)) + } + this.lastStart = Date.now() + return fn() + }) + this.tail = result.catch(() => {}) + return result + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run server/test/queue.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: serializing queue with min-interval pacing" +``` + +--- + +### Task 9: Discogs client + +**Files:** +- Create: `server/src/discogs.ts` +- Test: `server/test/discogs.test.ts`, `server/test/fixtures.ts` + +- [ ] **Step 1: Create `server/test/fixtures.ts`** — realistic Discogs API payloads: + +```ts +export const discogsSearchFixture = { + results: [ + { + id: 1001, + type: 'release', + title: 'The Cinematic Orchestra - Motion', + year: '1999', + format: ['CD', 'Album'], + label: ['Ninja Tune'], + country: 'UK', + catno: 'ZENCD012', + cover_image: 'https://img.discogs.com/big1.jpg', + thumb: 'https://img.discogs.com/small1.jpg', + }, + { + id: 1002, + type: 'release', + title: 'The Cinematic Orchestra - Motion', + year: '1999', + format: ['Vinyl', '2xLP'], + label: ['Ninja Tune'], + country: 'UK', + catno: 'ZEN012', + cover_image: 'https://img.discogs.com/big2.jpg', + thumb: 'https://img.discogs.com/small2.jpg', + }, + ], +} + +export const discogsReleaseFixture = { + id: 1001, + title: 'Motion', + artists: [{ name: 'The Cinematic Orchestra' }], + year: 1999, + formats: [{ name: 'CD', qty: '1' }], + labels: [{ name: 'Ninja Tune', catno: 'ZENCD012' }], + genres: ['Electronic', 'Jazz'], + country: 'UK', + images: [{ uri: 'https://img.discogs.com/full1.jpg' }], + tracklist: [ + { position: '1', title: 'Overture' }, + { position: '2', title: 'Theme de Yoyo' }, + ], + identifiers: [{ type: 'Barcode', value: '5021592210629' }], +} +``` + +- [ ] **Step 2: Write the failing test `server/test/discogs.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { + DiscogsClient, + DiscogsAuthError, + DiscogsRateLimitError, + mapSearchResult, + mapRelease, +} from '../src/discogs.js' +import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function stubFetch(routes: (url: string) => Response): typeof fetch { + return (async (input: any) => routes(String(input))) as typeof fetch +} + +describe('DiscogsClient', () => { + it('searches by barcode with token and maps results', async () => { + const seen: string[] = [] + const c = new DiscogsClient( + 'testtoken', + stubFetch((url) => { + seen.push(url) + return jsonResponse(discogsSearchFixture) + }) + ) + const results = await c.searchByBarcode('5021592210629') + expect(seen[0]).toContain('/database/search') + expect(seen[0]).toContain('barcode=5021592210629') + expect(seen[0]).toContain('type=release') + expect(seen[0]).toContain('token=testtoken') + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + formats: ['CD', 'Album'], + labels: ['Ninja Tune'], + country: 'UK', + catno: 'ZENCD012', + thumbUrl: 'https://img.discogs.com/small1.jpg', + }) + }) + + it('searches by text with optional format filter', async () => { + const seen: string[] = [] + const c = new DiscogsClient( + 't', + stubFetch((url) => { + seen.push(url) + return jsonResponse(discogsSearchFixture) + }) + ) + await c.searchByText('motion', 'Vinyl') + expect(seen[0]).toContain('q=motion') + expect(seen[0]).toContain('format=Vinyl') + }) + + it('throws DiscogsAuthError on 401', async () => { + const c = new DiscogsClient('bad', stubFetch(() => jsonResponse({ message: 'bad' }, 401))) + await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsAuthError) + }) + + it('throws DiscogsRateLimitError on 429', async () => { + const c = new DiscogsClient('t', stubFetch(() => jsonResponse({}, 429))) + await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsRateLimitError) + }) + + it('fetches full release with barcode identifiers', async () => { + const c = new DiscogsClient( + 't', + stubFetch((url) => { + expect(url).toContain('/releases/1001') + return jsonResponse(discogsReleaseFixture) + }) + ) + const release = await c.getRelease(1001) + expect(release.title).toBe('Motion') + expect(release.barcodes).toEqual(['5021592210629']) + expect(release.coverUrl).toBe('https://img.discogs.com/full1.jpg') + }) +}) + +describe('mappers', () => { + it('splits search title into artist/title and parses year', () => { + const mapped = mapSearchResult(discogsSearchFixture.results[0]) + expect(mapped.artist).toBe('The Cinematic Orchestra') + expect(mapped.title).toBe('Motion') + expect(mapped.year).toBe(1999) + }) + + it('maps full release', () => { + const mapped = mapRelease(discogsReleaseFixture) + expect(mapped.formats).toEqual(['CD']) + expect(mapped.labels).toEqual(['Ninja Tune']) + expect(mapped.tracklist).toEqual([ + { position: '1', title: 'Overture' }, + { position: '2', title: 'Theme de Yoyo' }, + ]) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run server/test/discogs.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `server/src/discogs.ts`** + +```ts +import type { SerialQueue } from './queue.js' + +export interface DiscogsReleaseSummary { + id: number + artist: string + title: string + year: number | null + formats: string[] + labels: string[] + country: string | null + catno: string | null + thumbUrl: string | null +} + +export interface DiscogsReleaseFull extends DiscogsReleaseSummary { + genres: string[] + tracklist: { position: string; title: string }[] + coverUrl: string | null + barcodes: string[] +} + +export class DiscogsError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + } +} +export class DiscogsAuthError extends DiscogsError { + constructor() { + super(401, 'discogs token rejected') + } +} +export class DiscogsRateLimitError extends DiscogsError { + constructor() { + super(429, 'discogs rate limit hit') + } +} + +const USER_AGENT = 'record-shop/0.1.0' + +function splitTitle(title: string): { artist: string; title: string } { + const idx = title.indexOf(' - ') + if (idx === -1) return { artist: '', title } + return { artist: title.slice(0, idx), title: title.slice(idx + 3) } +} + +export function toYear(year: unknown): number | null { + const n = Number(year) + return Number.isInteger(n) && n > 0 ? n : null +} + +export function mapSearchResult(r: any): DiscogsReleaseSummary { + const { artist, title } = splitTitle(String(r.title ?? '')) + return { + id: Number(r.id), + artist, + title, + year: toYear(r.year), + formats: Array.isArray(r.format) ? r.format.map(String) : [], + labels: Array.isArray(r.label) ? r.label.map(String) : [], + country: r.country ?? null, + catno: r.catno ?? null, + thumbUrl: r.thumb ?? r.cover_image ?? null, + } +} + +export function mapRelease(r: any): DiscogsReleaseFull { + return { + id: Number(r.id), + artist: Array.isArray(r.artists) && r.artists[0] ? String(r.artists[0].name) : '', + title: String(r.title ?? ''), + year: toYear(r.year), + formats: Array.isArray(r.formats) ? r.formats.map((f: any) => String(f.name)) : [], + labels: Array.isArray(r.labels) ? r.labels.map((l: any) => String(l.name)) : [], + country: r.country ?? null, + catno: Array.isArray(r.labels) && r.labels[0] ? (r.labels[0].catno ?? null) : null, + thumbUrl: r.thumb ?? (r.images?.[0]?.uri ?? null), + genres: Array.isArray(r.genres) ? r.genres.map(String) : [], + tracklist: Array.isArray(r.tracklist) + ? r.tracklist + .filter((t: any) => t.title) + .map((t: any) => ({ position: String(t.position ?? ''), title: String(t.title) })) + : [], + coverUrl: r.images?.[0]?.uri ?? null, + barcodes: Array.isArray(r.identifiers) + ? r.identifiers + .filter((i: any) => i.type === 'Barcode' && i.value) + .map((i: any) => String(i.value)) + : [], + } +} + +export class DiscogsClient { + private token: string + private fetchImpl: typeof fetch + private baseUrl: string + private queue?: SerialQueue + + constructor(token: string, fetchImpl: typeof fetch = fetch, baseUrl = 'https://api.discogs.com', queue?: SerialQueue) { + this.token = token + this.fetchImpl = fetchImpl + this.baseUrl = baseUrl + this.queue = queue + } + + private async get(path: string, params: Record = {}): Promise { + const doFetch = async (): Promise => { + const url = new URL(`${this.baseUrl}${path}`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + let res: Response + try { + res = await this.fetchImpl(url.toString(), { + headers: { + Authorization: `Discogs token=${this.token}`, + 'User-Agent': USER_AGENT, + Accept: 'application/json', + }, + }) + } catch { + throw new DiscogsError(0, 'could not reach api.discogs.com') + } + if (res.status === 401) throw new DiscogsAuthError() + if (res.status === 429) throw new DiscogsRateLimitError() + if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`) + return res.json() + } + return this.queue ? this.queue.run(doFetch) : doFetch() + } + + async searchByBarcode(barcode: string): Promise { + const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' }) + return (body.results ?? []).map(mapSearchResult) + } + + async searchByText(query: string, format?: string): Promise { + const params: Record = { q: query, type: 'release', per_page: '20' } + if (format) params.format = format + const body = await this.get('/database/search', params) + return (body.results ?? []).map(mapSearchResult) + } + + async getRelease(id: number): Promise { + const body = await this.get(`/releases/${id}`) + return mapRelease(body) + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run server/test/discogs.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: discogs client with barcode/text search and release fetch" +``` + +--- + +### Task 10: Subsonic client — library pagination + +**Files:** +- Modify: `server/src/subsonic.ts` (add `getAllAlbums`) +- Test: `server/test/subsonic.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/subsonic.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { SubsonicClient, SubsonicError } from '../src/subsonic.js' + +function subsonicResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function albumPage(count: number, offset: number) { + return { + 'subsonic-response': { + status: 'ok', + albumList2: { + album: Array.from({ length: count }, (_, i) => ({ + id: offset + i + 1, + name: `Album ${offset + i + 1}`, + artist: `Artist ${Math.floor((offset + i) / 10)}`, + })), + }, + }, + } +} + +describe('SubsonicClient', () => { + it('ping sends auth params and succeeds', async () => { + const seen: string[] = [] + const c = new SubsonicClient({ + url: 'http://navidrome.local', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + seen.push(String(input)) + return subsonicResponse({ 'subsonic-response': { status: 'ok' } }) + }) as typeof fetch, + }) + await c.ping() + expect(seen[0]).toContain('/rest/ping') + expect(seen[0]).toContain('u=sam') + expect(seen[0]).toContain('v=1.16.1') + expect(seen[0]).toContain('c=record-shop') + expect(seen[0]).toContain('f=json') + expect(seen[0]).toMatch(/t=[0-9a-f]{32}/) + }) + + it('ping raises auth error on failed status with code 40', async () => { + const c = new SubsonicClient({ + url: 'http://x', + username: 'sam', + password: 'bad', + fetchImpl: (async () => + subsonicResponse({ + 'subsonic-response': { + status: 'failed', + error: { code: 40, message: 'Wrong username or password.' }, + }, + })) as typeof fetch, + }) + const err = await c.ping().catch((e) => e) + expect(err).toBeInstanceOf(SubsonicError) + expect((err as SubsonicError).code).toBe('auth') + }) + + it('getAllAlbums paginates until a short page', async () => { + const seen: string[] = [] + const c = new SubsonicClient({ + url: 'http://navidrome.local', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + const url = new URL(String(input)) + seen.push(url.searchParams.get('offset') ?? '') + const offset = Number(url.searchParams.get('offset') ?? 0) + // first page: 500 albums, second: 3, third never requested + return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) + }) as typeof fetch, + }) + const albums = await c.getAllAlbums() + expect(seen).toEqual(['0', '500']) + expect(albums).toHaveLength(503) + expect(albums[0]).toEqual({ id: '1', title: 'Album 1', artist: 'Artist 0' }) + }) + + it('getAllAlbums reports progress', async () => { + const c = new SubsonicClient({ + url: 'http://x', + username: 'sam', + password: 'pass', + fetchImpl: (async (input: any) => { + const offset = Number(new URL(String(input)).searchParams.get('offset') ?? 0) + return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)) + }) as typeof fetch, + }) + const progress: number[] = [] + await c.getAllAlbums((_albums, done) => progress.push(done)) + expect(progress[progress.length - 1]).toBe(503) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/subsonic.test.ts` +Expected: FAIL — `getAllAlbums` is not a function. + +- [ ] **Step 3: Add `getAllAlbums` to the `SubsonicClient` class in `server/src/subsonic.ts`** (after `ping`): + +```ts + async getAllAlbums( + onProgress?: (albums: SubsonicAlbum[], done: number) => void + ): Promise { + const all: SubsonicAlbum[] = [] + const pageSize = 500 + for (let offset = 0; ; offset += pageSize) { + const envelope = await this.request('getAlbumList2', { + type: 'alphabeticalByName', + size: String(pageSize), + offset: String(offset), + }) + const list: any[] = envelope.albumList2?.album ?? [] + for (const a of list) { + all.push({ id: String(a.id), title: a.name ?? a.title ?? '', artist: a.artist ?? '' }) + } + onProgress?.(all, all.length) + if (list.length < pageSize || all.length > 20000) break + } + return all + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run server/test/subsonic.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: subsonic library pagination" +``` + +--- + +### Task 11: Artwork disk cache + +**Files:** +- Create: `server/src/artwork.ts` +- Test: `server/test/artwork.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/artwork.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { cacheArtwork } from '../src/artwork.js' + +function tempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'rs-artwork-')) +} + +function imageFetch(calls: { count: number }): typeof fetch { + return (async () => { + calls.count++ + return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }) + }) as typeof fetch +} + +describe('cacheArtwork', () => { + it('downloads and stores by url hash with content-type extension', async () => { + const dir = tempDir() + try { + const calls = { count: 0 } + const file = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', imageFetch(calls)) + expect(file).toMatch(/^[0-9a-f]{64}\.jpg$/) + expect(existsSync(path.join(dir, file as string))).toBe(true) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('second call for same url does not refetch', async () => { + const dir = tempDir() + try { + const calls = { count: 0 } + const fetcher = imageFetch(calls) + await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher) + const again = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher) + expect(calls.count).toBe(1) + expect(again).not.toBeNull() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns null for empty url and fetch failures without throwing', async () => { + const dir = tempDir() + try { + expect(await cacheArtwork(dir, '', imageFetch({ count: 0 }))).toBeNull() + const failing = (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + expect(await cacheArtwork(dir, 'https://x/y.jpg', failing)).toBeNull() + const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch + expect(await cacheArtwork(dir, 'https://x/y.jpg', notFound)).toBeNull() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('stores non-jpeg types with correct extension', async () => { + const dir = tempDir() + try { + const fetcher = (async () => + new Response(new Uint8Array([0x89, 0x50]), { + status: 200, + headers: { 'content-type': 'image/png' }, + })) as typeof fetch + const file = await cacheArtwork(dir, 'https://img.discogs.com/a.png', fetcher) + expect(file).toMatch(/\.png$/) + expect(readFileSync(path.join(dir, file as string)).length).toBeGreaterThan(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/artwork.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `server/src/artwork.ts`** + +```ts +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import path from 'node:path' + +const EXT_BY_TYPE: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', +} + +/** + * Downloads `url` into `artworkDir` keyed by its sha256 hash. Returns the + * stored file name (e.g. `abc123….jpg`) or null when the url is empty or the + * download fails — artwork caching is best-effort and must never break adds. + */ +export async function cacheArtwork( + artworkDir: string, + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + if (!url) return null + const key = createHash('sha256').update(url).digest('hex') + mkdirSync(artworkDir, { recursive: true }) + for (const ext of Object.values(EXT_BY_TYPE)) { + if (existsSync(path.join(artworkDir, key + ext))) return key + ext + } + let res: Response + try { + res = await fetchImpl(url) + } catch { + return null + } + if (!res.ok) return null + const type = (res.headers.get('content-type') ?? '').split(';')[0].trim() + const ext = EXT_BY_TYPE[type] ?? '.jpg' + const buf = Buffer.from(await res.arrayBuffer()) + await writeFile(path.join(artworkDir, key + ext), buf) + return key + ext +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run server/test/artwork.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: best-effort artwork disk cache" +``` + +--- + +### Task 12: Matcher — normalize, confident match, candidates + +**Files:** +- Create: `server/src/matcher.ts` +- Test: `server/test/matcher.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/matcher.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { normalize, isConfidentMatch, candidateAlbums } from '../src/matcher.js' + +describe('normalize', () => { + it('lowercases, strips punctuation, articles, accents, extra whitespace', () => { + expect(normalize('The Cinematic Orchestra!')).toBe('cinematic orchestra') + expect(normalize(' Blur: The Best of… ')).toBe('blur best of') + expect(normalize('Björk — Début')).toBe('bjork debut') + expect(normalize('A Tribe Called Quest')).toBe('tribe called quest') + }) +}) + +describe('isConfidentMatch', () => { + it('matches when normalized artist AND title are equal', () => { + expect( + isConfidentMatch( + { title: 'Motion!', artist: 'The Cinematic Orchestra' }, + { title: 'Motion', artist: 'Cinematic Orchestra' } + ) + ).toBe(true) + expect( + isConfidentMatch( + { title: 'Motion', artist: 'Massive Attack' }, + { title: 'Motion', artist: 'The Cinematic Orchestra' } + ) + ).toBe(false) + expect( + isConfidentMatch( + { title: 'Blue Lines', artist: 'Massive Attack' }, + { title: 'Motion', artist: 'Massive Attack' } + ) + ).toBe(false) + }) +}) + +describe('candidateAlbums', () => { + const albums = [ + { id: 1, title: 'Motion', artist: 'Someone Else' }, + { id: 2, title: 'Motion', artist: 'The Cinematic Orchestra' }, + { id: 3, title: 'Other Album', artist: 'The Cinematic Orchestra' }, + ] + + it('returns same-title albums with artist matches first', () => { + const candidates = candidateAlbums( + { title: 'Motion', artist: 'Cinematic Orchestra' }, + albums + ) + expect(candidates.map((c) => c.id)).toEqual([2, 1]) + }) + + it('caps candidates at 20', () => { + const many = Array.from({ length: 50 }, (_, i) => ({ + id: i, + title: 'Motion', + artist: `Artist ${i}`, + })) + expect(candidateAlbums({ title: 'Motion', artist: 'zzz' }, many)).toHaveLength(20) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/matcher.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `server/src/matcher.ts`** + +```ts +export interface Matchable { + title: string + artist: string +} + +/** + * Normalization for matching: lowercase, strip accents, strip punctuation, + * strip leading articles (the/a/an anywhere as standalone words), collapse + * whitespace. Spec definition of "normalized artist/title equal". + */ +export function normalize(input: string): string { + return input + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\b(the|a|an)\b/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function isConfidentMatch(a: Matchable, b: Matchable): boolean { + return normalize(a.title) === normalize(b.title) && normalize(a.artist) === normalize(b.artist) +} + +/** + * Albums whose normalized title equals the release's normalized title — + * the "ambiguous" candidate list shown when no confident match exists. + * Candidates with matching artist sort first. Capped at 20. + */ +export function candidateAlbums(release: Matchable, albums: T[]): T[] { + const title = normalize(release.title) + return albums + .filter((a) => normalize(a.title) === title) + .sort((a, b) => { + const am = normalize(a.artist) === normalize(release.artist) ? 0 : 1 + const bm = normalize(b.artist) === normalize(release.artist) ? 0 : 1 + return am - bm + }) + .slice(0, 20) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run server/test/matcher.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: title/artist normalization and matching" +``` + +--- + +### Task 13: Rip status resolution + +**Files:** +- Create: `server/src/ripstatus.ts` +- Test: `server/test/ripstatus.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/ripstatus.test.ts`** + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { openDatabase, type DB } from '../src/db.js' +import { resolveRipStatus } from '../src/ripstatus.js' + +describe('resolveRipStatus', () => { + let db: DB + let itemId: number + + beforeEach(() => { + db = openDatabase(':memory:') + db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run() + db.prepare( + "INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')" + ).run() + itemId = 10 + }) + + function addAlbum(id: number, title: string, artist: string) { + db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run( + id, + `sub-${id}`, + title, + artist + ) + } + + it('no album and no override → not_ripped', () => { + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + }) + + it('confident auto-match → ripped', () => { + addAlbum(1, 'Motion!', 'Cinematic Orchestra') + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) + + it('same title different artist → not_ripped', () => { + addAlbum(1, 'Motion', 'Massive Attack') + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + }) + + it('match link → ripped even without fuzzy match', () => { + addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra') + db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) + + it('manual override wins over everything (both directions)', () => { + addAlbum(1, 'Motion', 'The Cinematic Orchestra') + db.prepare('UPDATE collection_items SET rip_override = 0 WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped') + + db.prepare('UPDATE collection_items SET rip_override = 1 WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + + db.prepare('UPDATE collection_items SET rip_override = NULL WHERE id = 10').run() + expect(resolveRipStatus(db, 1, itemId)).toBe('ripped') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/ripstatus.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `server/src/ripstatus.ts`** + +```ts +import type { DB } from './db.js' +import { isConfidentMatch } from './matcher.js' + +export type RipStatus = 'ripped' | 'not_ripped' + +/** + * Resolution order per spec: + * 1. manual rip_override (non-null) wins + * 2. else a stored match_link means ripped + * 3. else confident fuzzy match against the user's digital_albums + */ +export function resolveRipStatus(db: DB, userId: number, itemId: number): RipStatus { + const item = db + .prepare('SELECT id, title, artist, rip_override FROM collection_items WHERE id = ? AND user_id = ?') + .get(itemId, userId) as + | { id: number; title: string; artist: string; rip_override: number | null } + | undefined + if (!item) return 'not_ripped' + + if (item.rip_override !== null && item.rip_override !== undefined) { + return item.rip_override === 1 ? 'ripped' : 'not_ripped' + } + + const link = db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(itemId) + if (link) return 'ripped' + + const albums = db + .prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?') + .all(userId) as { title: string; artist: string }[] + return albums.some((a) => isConfidentMatch(item, a)) ? 'ripped' : 'not_ripped' +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run server/test/ripstatus.test.ts && npm test` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: rip status resolution with override and match links" +``` + +--- + +### Task 14: Lookup routes — barcode, search, release preview + +**Files:** +- Create: `server/src/routes/lookupRoutes.ts` +- Test: `server/test/lookup.test.ts` +- Modify: `server/src/app.ts` + +The release preview endpoint powers the confirm screen: it returns the full release, whether the user already owns it (`duplicate`), and the rip check as `ripMatch`: `'ripped'` (confident match), `'not_ripped'` (no match, no candidates), or `'ambiguous'` (candidates exist → UI asks once, stored via `match_links`). + +- [ ] **Step 1: Write the failing test `server/test/lookup.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, auth } from './helpers.js' +import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js' + +function stubFetch(routes: (url: string) => Response): typeof fetch { + return (async (input: any) => routes(String(input))) as typeof fetch +} + +function discogsStub(): typeof fetch { + return stubFetch((url) => { + if (url.includes('/database/search')) { + return new Response(JSON.stringify(discogsSearchFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.includes('/releases/1001')) { + return new Response(JSON.stringify(discogsReleaseFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('nope', { status: 404 }) + }) +} + +async function appWithToken(discogsFetch: typeof fetch) { + const app = await buildTestApp(discogsFetch) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: 'testtoken' }, + }) + return { app, cookie } +} + +describe('GET /api/lookup/barcode/:code', () => { + it('returns candidates on match', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/5021592210629', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.candidates).toHaveLength(2) + expect(body.candidates[0]).toMatchObject({ + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + }) + await app.close() + }) + + it('returns 404 not_found when discogs has zero results', async () => { + const empty = stubFetch((url) => + url.includes('/database/search') + ? new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + : new Response('nope', { status: 404 }) + ) + const { app, cookie } = await appWithToken(empty) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(404) + expect(res.json()).toEqual({ error: 'not_found' }) + await app.close() + }) + + it('returns 409 no_discogs_token when token unset', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(409) + expect(res.json()).toEqual({ error: 'no_discogs_token' }) + await app.close() + }) + + it('maps discogs auth failure to 502 discogs_auth', async () => { + const unauthorized = stubFetch(() => new Response('bad token', { status: 401 })) + const { app, cookie } = await appWithToken(unauthorized) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(502) + expect(res.json()).toEqual({ error: 'discogs_auth' }) + await app.close() + }) +}) + +describe('GET /api/lookup/search', () => { + it('returns candidates for text query with format filter', async () => { + const seen: string[] = [] + const { app, cookie } = await appWithToken( + stubFetch((url) => { + seen.push(url) + return new Response(JSON.stringify(discogsSearchFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/lookup/search?q=motion&format=Vinyl', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + expect(seen[0]).toContain('q=motion') + expect(seen[0]).toContain('format=Vinyl') + expect(res.json().candidates).toHaveLength(2) + await app.close() + }) +}) + +describe('GET /api/lookup/release/:id', () => { + it('returns full release with duplicate and ripMatch', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.release).toMatchObject({ + id: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + barcodes: ['5021592210629'], + }) + expect(body.duplicate).toBe(false) + expect(body.ripMatch).toBe('not_ripped') + expect(body.matchCandidates).toEqual([]) + await app.close() + }) + + it('reports duplicate when release already in collection', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.json().duplicate).toBe(true) + await app.close() + }) + + it('reports ripped on confident match against digital albums', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, + }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.json().ripMatch).toBe('ripped') + await app.close() + }) + + it('reports ambiguous with matchCandidates on same-title different-artist', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'Somebody Else' }] }, + }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + const body = res.json() + expect(body.ripMatch).toBe('ambiguous') + expect(body.matchCandidates).toHaveLength(1) + expect(body.matchCandidates[0]).toMatchObject({ title: 'Motion', artist: 'Somebody Else' }) + await app.close() + }) +}) +``` + +Note: `POST /api/library/albums/test-seed` is a **test-only seeding route** (Task 16 defines it; it 404s unless Vitest is running). If you prefer not to have it, seed `digital_albums` rows directly via the db handle returned by `buildTestAppWithDb` instead — the assertions stay identical. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/lookup.test.ts` +Expected: FAIL — 404s. + +- [ ] **Step 3: Implement `server/src/routes/lookupRoutes.ts`** + +```ts +import { FastifyInstance } from 'fastify' +import { + DiscogsClient, + DiscogsAuthError, + DiscogsRateLimitError, + DiscogsError, +} from '../discogs.js' +import { isConfidentMatch, candidateAlbums } from '../matcher.js' +import { requireAuth } from './authRoutes.js' +import { getSettings } from './settingsRoutes.js' + +export function discogsErrorStatus(err: unknown): { code: number; body: Record } { + if (err instanceof DiscogsAuthError) return { code: 502, body: { error: 'discogs_auth' } } + if (err instanceof DiscogsRateLimitError) return { code: 429, body: { error: 'discogs_rate_limited' } } + if (err instanceof DiscogsError) return { code: 502, body: { error: 'discogs_error' } } + return { code: 502, body: { error: 'discogs_unreachable' } } +} + +export function discogsClientFor(request: any): DiscogsClient | null { + const s = getSettings(request.server.db, request.user.id) + if (!s.discogs_token) return null + return new DiscogsClient(s.discogs_token, request.server.fetchImpl, 'https://api.discogs.com', request.server.discogsQueue) +} + +export async function registerLookupRoutes(app: FastifyInstance): Promise { + app.get('/api/lookup/barcode/:code', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + try { + const candidates = await client.searchByBarcode((request.params as { code: string }).code) + if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' }) + return { candidates } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) + + app.get('/api/lookup/search', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const { q, format } = request.query as { q?: string; format?: string } + if (!q) return reply.code(400).send({ error: 'missing_query' }) + try { + const candidates = await client.searchByText(q, format || undefined) + if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' }) + return { candidates } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) + + app.get('/api/lookup/release/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const id = Number((request.params as { id: string }).id) + try { + const release = await client.getRelease(id) + const db = request.server.db + const userId = request.user.id + const duplicate = !!db + .prepare('SELECT id FROM collection_items WHERE user_id = ? AND discogs_release_id = ?') + .get(userId, id) + const albums = db + .prepare('SELECT id, title, artist FROM digital_albums WHERE user_id = ?') + .all(userId) as { id: number; title: string; artist: string }[] + const confident = albums.some((a) => isConfidentMatch(release, a)) + const matchCandidates = confident ? [] : candidateAlbums(release, albums) + return { + release, + duplicate, + ripMatch: confident ? 'ripped' : matchCandidates.length > 0 ? 'ambiguous' : 'not_ripped', + matchCandidates, + } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) +} +``` + +- [ ] **Step 4: Modify `server/src/app.ts`** — add the queue decoration and register routes. Full file: + +```ts +import Fastify, { FastifyInstance } from 'fastify' +import Database from 'better-sqlite3' +import cookie from '@fastify/cookie' +import type { Config } from './config.js' +import { SerialQueue } from './queue.js' +import { registerAuthRoutes } from './routes/authRoutes.js' +import { registerSettingsRoutes } from './routes/settingsRoutes.js' +import { registerLookupRoutes } from './routes/lookupRoutes.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Database.Database + config: Config + fetchImpl: typeof fetch + discogsQueue: SerialQueue + } + interface FastifyRequest { + user?: import('./auth.js').UserRow + } +} + +export interface AppOptions { + db: Database.Database + config: Config + fetchImpl?: typeof fetch +} + +export async function buildApp(opts: AppOptions): Promise { + const app = Fastify({ logger: false }) + app.decorate('db', opts.db) + app.decorate('config', opts.config) + app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 })) + + await app.register(cookie) + await registerAuthRoutes(app) + await registerSettingsRoutes(app) + await registerLookupRoutes(app) + + app.get('/api/health', async () => ({ ok: true })) + return app +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run server/test/lookup.test.ts && npm test && npm run typecheck` +Expected: lookup FAILS only on the two tests that use `/api/library/albums/test-seed` (route doesn't exist yet). Everything else passes. + +- [ ] **Step 6: Commit (known-red tests documented for Task 16)** + +```bash +git add -A && git commit -m "feat: discogs lookup routes (barcode/search/release preview)" +``` + +--- + +### Task 15: Collection routes + +**Files:** +- Create: `server/src/routes/collectionRoutes.ts` +- Test: `server/test/collection.test.ts` +- Modify: `server/src/app.ts` + +- [ ] **Step 1: Write the failing test `server/test/collection.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js' +import { discogsReleaseFixture } from './fixtures.js' + +function discogsStub(): typeof fetch { + return (async (input: any) => { + const url = String(input) + if (url.includes('/releases/1001')) { + return new Response(JSON.stringify(discogsReleaseFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function appWithToken() { + const app = await buildTestApp(discogsStub()) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: 'testtoken' }, + }) + return { app, cookie } +} + +describe('collection routes', () => { + it('adds a release from discogs, returns item with artwork and rip status', async () => { + const { app, cookie } = await appWithToken() + const res = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001, barcode: '5021592210629' }, + }) + expect(res.statusCode).toBe(200) + const item = res.json() + expect(item).toMatchObject({ + discogsReleaseId: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + labels: ['Ninja Tune'], + catno: 'ZENCD012', + barcodes: ['5021592210629'], + ripOverride: null, + ripStatus: 'not_ripped', + }) + expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/) + await app.close() + }) + + it('rejects duplicate add with 409', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + const again = await app.inject({ + method: 'POST', + url: '/api/collection', + ...auth(cookie), + payload: { releaseId: 1001 }, + }) + expect(again.statusCode).toBe(409) + expect(again.json()).toEqual({ error: 'duplicate' }) + await app.close() + }) + + it('lists items with counts and filters (format, ripped, q)', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + + const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) }) + expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 }) + expect(all.json().items).toHaveLength(1) + + const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) }) + expect(cd.json().items).toHaveLength(1) + const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) }) + expect(vinyl.json().items).toHaveLength(0) + + const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) }) + expect(ripped.json().items).toHaveLength(0) + const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) }) + expect(notRipped.json().items).toHaveLength(1) + + const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) }) + expect(q.json().items).toHaveLength(1) + const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) }) + expect(qMiss.json().items).toHaveLength(0) + await app.close() + }) + + it('detail, rip override, match link, re-match search, delete', 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.statusCode).toBe(200) + expect(detail.json().tracklist).toEqual([ + { position: '1', title: 'Overture' }, + { position: '2', title: 'Theme de Yoyo' }, + ]) + + // manual rip override + const rip = await app.inject({ + method: 'PATCH', + url: `/api/collection/${id}/rip`, + ...auth(cookie), + payload: { ripped: true }, + }) + expect(rip.json().ripOverride).toBe(true) + expect(rip.json().ripStatus).toBe('ripped') + const clear = await app.inject({ + method: 'PATCH', + url: `/api/collection/${id}/rip`, + ...auth(cookie), + payload: { ripped: null }, + }) + expect(clear.json().ripOverride).toBeNull() + expect(clear.json().ripStatus).toBe('not_ripped') + + // match link (simulates confirmed ambiguous match) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] }, + }) + const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) }) + const albumId = albums.json().albums[0].id as number + const linked = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId }, + }) + expect(linked.json().ripStatus).toBe('ripped') + + // clear link + const unlinked = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId: null }, + }) + expect(unlinked.json().ripStatus).toBe('not_ripped') + + const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) }) + expect(del.statusCode).toBe(200) + const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) }) + expect(after.statusCode).toBe(404) + await app.close() + }) + + it('rejects match to another users album', 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/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] }, + }) + // album id 99 does not exist for this user + const res = await app.inject({ + method: 'POST', + url: `/api/collection/${id}/match`, + ...auth(cookie), + payload: { albumId: 99 }, + }) + expect(res.statusCode).toBe(404) + await app.close() + }) + + it('second user cannot see first users items', async () => { + const { app, cookie } = await appWithToken() + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + const bobLogin = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'bob', password: 'bobpass123' }, + }) + const bobCookie = getCookie(bobLogin) + const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) }) + expect(list.json().items).toHaveLength(0) + await app.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/collection.test.ts` +Expected: FAIL — 404s (and the two `test-seed` tests fail until Task 16). + +- [ ] **Step 3: Implement `server/src/routes/collectionRoutes.ts`** + +```ts +import { FastifyInstance } from 'fastify' +import { cacheArtwork } from '../artwork.js' +import { resolveRipStatus } from '../ripstatus.js' +import { requireAuth } from './authRoutes.js' +import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js' + +interface ItemRow { + id: number + user_id: number + discogs_release_id: number + title: string + artist: string + year: number | null + formats: string + genres: string + labels: string + tracklist: string + catno: string | null + country: string | null + cover_url: string | null + local_artwork_path: string | null + barcodes: string + rip_override: number | null + date_added: string +} + +export function rowToItem(db: any, row: ItemRow) { + return { + id: row.id, + discogsReleaseId: row.discogs_release_id, + title: row.title, + artist: row.artist, + year: row.year, + formats: JSON.parse(row.formats), + genres: JSON.parse(row.genres), + labels: JSON.parse(row.labels), + tracklist: JSON.parse(row.tracklist), + catno: row.catno, + country: row.country, + artworkUrl: row.local_artwork_path ? `/artwork/${row.local_artwork_path}` : row.cover_url, + barcodes: JSON.parse(row.barcodes), + dateAdded: row.date_added, + ripOverride: row.rip_override === null ? null : row.rip_override === 1, + ripStatus: resolveRipStatus(db, row.user_id, row.id), + } +} + +function getItem(db: any, userId: number, id: number): ItemRow | undefined { + return db + .prepare('SELECT * FROM collection_items WHERE id = ? AND user_id = ?') + .get(id, userId) as ItemRow | undefined +} + +export async function registerCollectionRoutes(app: FastifyInstance): Promise { + app.post('/api/collection', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const { releaseId, barcode, matchAlbumId } = (request.body ?? {}) as { + releaseId?: number + barcode?: string + matchAlbumId?: number + } + if (typeof releaseId !== 'number') return reply.code(400).send({ error: 'invalid_input' }) + + let release + try { + release = await client.getRelease(releaseId) + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + + const db = request.server.db + const userId = request.user.id + const artworkFile = release.coverUrl + ? await cacheArtwork(request.server.config.artworkDir, release.coverUrl, request.server.fetchImpl) + : null + const barcodes = barcode && !release.barcodes.includes(barcode) ? [...release.barcodes, barcode] : release.barcodes + + let itemId: number + try { + const info = db + .prepare( + `INSERT INTO collection_items + (user_id, discogs_release_id, title, artist, year, formats, genres, labels, tracklist, catno, country, cover_url, local_artwork_path, barcodes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + userId, + release.id, + release.title, + release.artist, + release.year, + JSON.stringify(release.formats), + JSON.stringify(release.genres), + JSON.stringify(release.labels), + JSON.stringify(release.tracklist), + release.catno, + release.country, + release.coverUrl, + artworkFile, + JSON.stringify(barcodes) + ) + itemId = Number(info.lastInsertRowid) + } catch (err: any) { + if (String(err.message).includes('UNIQUE constraint failed')) { + return reply.code(409).send({ error: 'duplicate' }) + } + throw err + } + + if (matchAlbumId != null) { + const album = db + .prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?') + .get(matchAlbumId, userId) + if (!album) return reply.code(404).send({ error: 'album_not_found' }) + db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)').run( + userId, + itemId, + matchAlbumId + ) + } + + const row = getItem(db, userId, itemId) as ItemRow + return reply.code(200).send(rowToItem(db, row)) + }) + + app.get('/api/collection', { preHandler: [requireAuth] }, async (request) => { + const db = request.server.db + const userId = request.user.id + const rows = db + .prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added DESC, id DESC') + .all(userId) as ItemRow[] + const items = rows.map((row) => rowToItem(db, row)) + + const counts = { + total: items.length, + ripped: items.filter((i) => i.ripStatus === 'ripped').length, + notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length, + } + + const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string } + let filtered = items + if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format))) + if (ripped === 'ripped' || ripped === 'not_ripped') { + filtered = filtered.filter((i) => i.ripStatus === ripped) + } + if (q) { + const needle = q.toLowerCase() + filtered = filtered.filter( + (i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle) + ) + } + return { items: filtered, counts } + }) + + app.get('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const row = getItem(db, request.user.id, Number((request.params as { id: string }).id)) + if (!row) return reply.code(404).send({ error: 'not_found' }) + return rowToItem(db, row) + }) + + app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const info = db + .prepare('DELETE FROM collection_items WHERE id = ? AND user_id = ?') + .run(Number((request.params as { id: string }).id), request.user.id) + if (info.changes === 0) return reply.code(404).send({ error: 'not_found' }) + return { ok: true } + }) + + app.patch('/api/collection/:id/rip', { 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 { ripped } = (request.body ?? {}) as { ripped?: boolean | null } + if (ripped !== true && ripped !== false && ripped !== null) { + return reply.code(400).send({ error: 'invalid_input' }) + } + const value = ripped === null ? null : ripped ? 1 : 0 + const info = db + .prepare('UPDATE collection_items SET rip_override = ? WHERE id = ? AND user_id = ?') + .run(value, id, userId) + if (info.changes === 0) return reply.code(404).send({ error: 'not_found' }) + return rowToItem(db, getItem(db, userId, id) as ItemRow) + }) + + app.post('/api/collection/:id/match', { preHandler: [requireAuth] }, async (request, reply) => { + const db = request.server.db + const userId = request.user.id + const id = Number((request.params as { id: string }).id) + if (!getItem(db, userId, id)) return reply.code(404).send({ error: 'not_found' }) + const { albumId } = (request.body ?? {}) as { albumId?: number | null } + + if (albumId === null || albumId === undefined) { + db.prepare('DELETE FROM match_links WHERE user_id = ? AND item_id = ?').run(userId, id) + } else { + const album = db + .prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?') + .get(albumId, userId) + if (!album) return reply.code(404).send({ error: 'album_not_found' }) + db.prepare( + `INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?) + ON CONFLICT(user_id, item_id) DO UPDATE SET album_id = excluded.album_id` + ).run(userId, id, albumId) + } + return rowToItem(db, getItem(db, userId, id) as ItemRow) + }) +} +``` + +- [ ] **Step 4: Register in `server/src/app.ts`** — add import and registration line: + +```ts +import { registerCollectionRoutes } from './routes/collectionRoutes.js' +``` + +```ts + await registerCollectionRoutes(app) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run server/test/collection.test.ts && npm test && npm run typecheck` +Expected: all collection tests pass except the two that use the `test-seed` route (Task 16). Everything else green. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: collection CRUD with artwork caching, rip override and match links" +``` + +--- + +### Task 16: Library sync manager + routes + +**Files:** +- Create: `server/src/sync.ts`, `server/src/routes/libraryRoutes.ts` +- Test: `server/test/library.test.ts` +- Modify: `server/src/app.ts`, `server/test/lookup.test.ts` + `server/test/collection.test.ts` (seed route now available) + +- [ ] **Step 1: Write the failing test `server/test/library.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js' + +function albumPage(count: number, offset: number) { + return { + 'subsonic-response': { + status: 'ok', + albumList2: { + album: Array.from({ length: count }, (_, i) => ({ + id: offset + i + 1, + name: `Album ${offset + i + 1}`, + artist: `Artist ${Math.floor((offset + i) / 10)}`, + })), + }, + }, + } +} + +function subsonicStub(): 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 offset = Number(url.searchParams.get('offset') ?? 0) + return new Response( + JSON.stringify(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function appWithSubsonic() { + const app = await buildTestApp(subsonicStub()) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://navidrome.local', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + return { app, cookie } +} + +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 in time') +} + +describe('library sync', () => { + it('sync paginates subsonic and caches albums', async () => { + const { app, cookie } = await appWithSubsonic() + const start = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + expect(start.statusCode).toBe(202) + + const state = await waitForDone(app, cookie) + expect(state.status).toBe('done') + expect(state.albums).toBe(503) + expect(state.lastSyncedAt).toBeTruthy() + + const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album 7', ...auth(cookie) }) + expect(albums.json().albums.length).toBeGreaterThan(0) + + // idempotent re-sync: count stays 503 (upsert, no duplicates) + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + const second = await waitForDone(app, cookie) + expect(second.albums).toBe(503) + await app.close() + }) + + it('returns 409 without subsonic config', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + expect(res.statusCode).toBe(409) + expect(res.json()).toEqual({ error: 'no_subsonic_config' }) + await app.close() + }) + + it('reports error state when album fetch fails after valid ping', async () => { + // ping succeeds (settings validation passes) but getAlbumList2 fails (sync errors) + const fetcher = (async (input: any) => { + if (String(input).includes('/rest/ping')) { + return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + const app = await buildTestApp(fetcher) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { + subsonicUrl: 'http://flaky.local', + subsonicUsername: 'sam', + subsonicPassword: 'pass', + }, + }) + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + const state = await waitForDone(app, cookie) + expect(state.status).toBe('error') + expect(state.error).toBeTruthy() + await app.close() + }) + + it('albums search is scoped per user', async () => { + const { app, cookie } = await appWithSubsonic() + await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) }) + await waitForDone(app, cookie) + + const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123') + const empty = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album', ...auth(bobCookie) }) + expect(empty.json().albums).toHaveLength(0) + await app.close() + }) + + it('test-seed route inserts albums directly (used by lookup/collection tests)', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().inserted).toBe(1) + await app.close() + }) + + it('saving subsonic settings triggers a first sync', async () => { + const { app, cookie } = await appWithSubsonic() + const state = await waitForDone(app, cookie) + expect(state.status).toBe('done') + expect(state.albums).toBe(503) + await app.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/library.test.ts` +Expected: FAIL — 404s. + +- [ ] **Step 3: Implement `server/src/sync.ts`** + +```ts +import type { DB } from './db.js' +import { SubsonicClient, SubsonicError } from './subsonic.js' + +export interface SyncState { + status: 'idle' | 'running' | 'done' | 'error' + error: string | null + lastSyncedAt: string | null + albums: number +} + +export function initialSyncState(): SyncState { + return { status: 'idle', error: null, lastSyncedAt: null, albums: 0 } +} + +export class SyncManager { + private states = new Map() + + constructor( + private db: DB, + private fetchImpl: typeof fetch + ) {} + + getState(userId: number): SyncState { + return this.states.get(userId) ?? initialSyncState() + } + + /** Returns false when a sync is already running. */ + start(userId: number, config: { url: string; username: string; password: string }): boolean { + const state = this.getState(userId) + if (state.status === 'running') return false + this.states.set(userId, { ...state, status: 'running', error: null }) + void this.run(userId, config) + return true + } + + private async run( + userId: number, + config: { url: string; username: string; password: string } + ): Promise { + const client = new SubsonicClient({ ...config, fetchImpl: this.fetchImpl }) + try { + const albums = await client.getAllAlbums() + const upsert = this.db.prepare( + `INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist` + ) + const insertAll = this.db.transaction((rows: { id: string; title: string; artist: string }[]) => { + for (const r of rows) upsert.run(userId, r.id, r.title, r.artist) + }) + insertAll(albums) + this.states.set(userId, { + status: 'done', + error: null, + lastSyncedAt: new Date().toISOString(), + albums: albums.length, + }) + } catch (err) { + const state = this.getState(userId) + this.states.set(userId, { + ...state, + status: 'error', + error: err instanceof SubsonicError ? err.message : 'sync failed', + }) + } + } +} +``` + +- [ ] **Step 4: Implement `server/src/routes/libraryRoutes.ts`** + +```ts +import { FastifyInstance } from 'fastify' +import { requireAuth } from './authRoutes.js' +import { getSettings, subsonicConfigComplete } from './settingsRoutes.js' + +export async function registerLibraryRoutes(app: FastifyInstance): Promise { + app.get('/api/library/sync', { preHandler: [requireAuth] }, async (request) => { + return request.server.sync.getState(request.user.id) + }) + + app.post('/api/library/sync', { preHandler: [requireAuth] }, async (request, reply) => { + const s = getSettings(request.server.db, request.user.id) + if (!subsonicConfigComplete(s)) { + return reply.code(409).send({ error: 'no_subsonic_config' }) + } + request.server.sync.start(request.user.id, { + url: s.subsonic_url as string, + username: s.subsonic_username as string, + password: s.subsonic_password as string, + }) + return reply.code(202).send(request.server.sync.getState(request.user.id)) + }) + + app.get('/api/library/albums', { preHandler: [requireAuth] }, async (request) => { + const { q } = request.query as { q?: string } + const userId = request.user.id + let rows: { id: number; subsonic_id: string; title: string; artist: string }[] + if (q) { + const needle = `%${q}%` + rows = request.server.db + .prepare( + `SELECT id, subsonic_id, title, artist FROM digital_albums + WHERE user_id = ? AND (title LIKE ? OR artist LIKE ?) + ORDER BY artist, title LIMIT 50` + ) + .all(userId, needle, needle) + } else { + rows = request.server.db + .prepare( + `SELECT id, subsonic_id, title, artist FROM digital_albums + WHERE user_id = ? ORDER BY artist, title LIMIT 50` + ) + .all(userId) + } + return { + albums: rows.map((r) => ({ id: r.id, subsonicId: r.subsonic_id, title: r.title, artist: r.artist })), + } + }) + + // Test-only seeding route so lookup/collection tests can populate digital + // albums without a live Subsonic server. Disabled outside tests. + app.post('/api/library/albums/test-seed', { preHandler: [requireAuth] }, async (request, reply) => { + if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') { + return reply.code(404).send({ error: 'not_found' }) + } + const { albums } = (request.body ?? {}) as { + albums?: { subsonicId: string; title: string; artist: string }[] + } + const userId = request.user.id + const insert = request.server.db.prepare( + `INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist` + ) + let inserted = 0 + for (const a of albums ?? []) { + insert.run(userId, a.subsonicId, a.title, a.artist) + inserted++ + } + return { inserted } + }) +} +``` + +- [ ] **Step 4b: Trigger first sync when settings are saved** — in `server/src/routes/settingsRoutes.ts`, replace the end of the PUT handler (currently `return settingsView(getSettings(db, userId))`) with: + +```ts + const updated = getSettings(db, userId) + if (subsonicConfigComplete(updated)) { + request.server.sync.start(userId, { + url: updated.subsonic_url as string, + username: updated.subsonic_username as string, + password: updated.subsonic_password as string, + }) + } + return settingsView(updated) +``` + +(This is why `sync` must be decorated on the app before this change — Step 5 below does that.) + +- [ ] **Step 5: Modify `server/src/app.ts`** — decorate with `sync`, register library routes, and force `VITEST` env for tests. Full file: + +```ts +import Fastify, { FastifyInstance } from 'fastify' +import Database from 'better-sqlite3' +import cookie from '@fastify/cookie' +import type { Config } from './config.js' +import { SerialQueue } from './queue.js' +import { SyncManager } from './sync.js' +import { registerAuthRoutes } from './routes/authRoutes.js' +import { registerSettingsRoutes } from './routes/settingsRoutes.js' +import { registerLookupRoutes } from './routes/lookupRoutes.js' +import { registerCollectionRoutes } from './routes/collectionRoutes.js' +import { registerLibraryRoutes } from './routes/libraryRoutes.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Database.Database + config: Config + fetchImpl: typeof fetch + discogsQueue: SerialQueue + sync: SyncManager + } + interface FastifyRequest { + user?: import('./auth.js').UserRow + } +} + +export interface AppOptions { + db: Database.Database + config: Config + fetchImpl?: typeof fetch +} + +export async function buildApp(opts: AppOptions): Promise { + const app = Fastify({ logger: false }) + app.decorate('db', opts.db) + app.decorate('config', opts.config) + app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 })) + app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch)) + + await app.register(cookie) + await registerAuthRoutes(app) + await registerSettingsRoutes(app) + await registerLookupRoutes(app) + await registerCollectionRoutes(app) + await registerLibraryRoutes(app) + + app.get('/api/health', async () => ({ ok: true })) + return app +} +``` + +- [ ] **Step 6: Run the whole suite — every previously-known-red test now passes** + +Run: `npm test && npm run typecheck` +Expected: ALL PASS, including the `test-seed` tests in `lookup.test.ts` and `collection.test.ts`. + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "feat: background subsonic library sync with per-user state" +``` + +--- + +### Task 17: Static SPA + artwork serving + +**Files:** +- Modify: `server/src/app.ts` +- Test: `server/test/static.test.ts` + +- [ ] **Step 1: Write the failing test `server/test/static.test.ts`** + +```ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { testConfig } from './helpers.js' +import { openDatabase } from '../src/db.js' +import { buildApp } from '../src/app.js' + +describe('static serving', () => { + let dir: string + + beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), 'rs-static-')) + mkdirSync(path.join(dir, 'web', 'dist'), { recursive: true }) + mkdirSync(path.join(dir, 'artwork'), { recursive: true }) + writeFileSync(path.join(dir, 'web', 'dist', 'index.html'), 'record-shop') + writeFileSync(path.join(dir, 'artwork', 'abc.jpg'), 'fakejpeg') + }) + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + async function build() { + const config = { ...testConfig(), artworkDir: path.join(dir, 'artwork') } + const app = await buildApp({ + db: openDatabase(':memory:'), + config, + webDist: path.join(dir, 'web', 'dist'), + }) + return app + } + + it('serves index.html at / and SPA-falls back for client routes', async () => { + const app = await build() + const root = await app.inject({ method: 'GET', url: '/' }) + expect(root.statusCode).toBe(200) + expect(root.body).toContain('record-shop') + const spa = await app.inject({ method: 'GET', url: '/library' }) + expect(spa.statusCode).toBe(200) + expect(spa.body).toContain('record-shop') + await app.close() + }) + + it('serves artwork files', async () => { + const app = await build() + const res = await app.inject({ method: 'GET', url: '/artwork/abc.jpg' }) + expect(res.statusCode).toBe(200) + expect(res.body).toBe('fakejpeg') + await app.close() + }) + + it('unknown api routes return json 404, not the SPA', async () => { + const app = await build() + const res = await app.inject({ method: 'GET', url: '/api/nope' }) + expect(res.statusCode).toBe(404) + expect(res.json()).toEqual({ error: 'not_found' }) + await app.close() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run server/test/static.test.ts` +Expected: FAIL — `webDist` not an option / 404s. + +- [ ] **Step 3: Modify `server/src/app.ts`** — add static serving. `AppOptions` gains `webDist?: string`; when set, register artwork + SPA static and the not-found handler. Full file: + +```ts +import Fastify, { FastifyInstance } from 'fastify' +import Database from 'better-sqlite3' +import cookie from '@fastify/cookie' +import fastifyStatic from '@fastify/static' +import path from 'node:path' +import { existsSync } from 'node:fs' +import type { Config } from './config.js' +import { SerialQueue } from './queue.js' +import { SyncManager } from './sync.js' +import { registerAuthRoutes } from './routes/authRoutes.js' +import { registerSettingsRoutes } from './routes/settingsRoutes.js' +import { registerLookupRoutes } from './routes/lookupRoutes.js' +import { registerCollectionRoutes } from './routes/collectionRoutes.js' +import { registerLibraryRoutes } from './routes/libraryRoutes.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Database.Database + config: Config + fetchImpl: typeof fetch + discogsQueue: SerialQueue + sync: SyncManager + } + interface FastifyRequest { + user?: import('./auth.js').UserRow + } +} + +export interface AppOptions { + db: Database.Database + config: Config + fetchImpl?: typeof fetch + /** Directory of the built SPA. Defaults to web/dist when it exists. */ + webDist?: string +} + +export async function buildApp(opts: AppOptions): Promise { + const app = Fastify({ logger: false }) + app.decorate('db', opts.db) + app.decorate('config', opts.config) + app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 })) + app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch)) + + await app.register(cookie) + await registerAuthRoutes(app) + await registerSettingsRoutes(app) + await registerLookupRoutes(app) + await registerCollectionRoutes(app) + await registerLibraryRoutes(app) + + // artwork cache (always available) + await app.register(fastifyStatic, { + root: opts.config.artworkDir, + prefix: '/artwork/', + decorateReply: false, + }) + + const webDist = opts.webDist ?? path.resolve('web/dist') + const hasWeb = existsSync(webDist) + if (hasWeb) { + await app.register(fastifyStatic, { root: webDist, prefix: '/' }) + } + + app.setNotFoundHandler((request, reply) => { + const url = request.raw.url ?? '' + if (url.startsWith('/api')) { + return reply.code(404).send({ error: 'not_found' }) + } + if (hasWeb) { + return reply.sendFile('index.html') + } + return reply.code(404).send({ error: 'not_found' }) + }) + + app.get('/api/health', async () => ({ ok: true })) + return app +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test && npm run typecheck` +Expected: ALL PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: serve built SPA with fallback and artwork cache" +``` + +--- + +### Task 18: Docker deployment + README quickstart + +**Files:** +- Create: `Dockerfile`, `.dockerignore`, `docker-compose.yml` +- Modify: `README.md` + +- [ ] **Step 1: Create `.dockerignore`** + +``` +node_modules +server/dist +web/dist +data +.git +.gitignore +*.log +.DS_Store +``` + +- [ ] **Step 2: Create `Dockerfile`** + +```dockerfile +# ---- build stage: deps + compile + test ---- +FROM node:22-slim AS build +WORKDIR /app + +# Install build tools as a fallback for native modules without prebuilds +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json vitest.config.ts ./ +COPY server ./server +RUN npm run build:server && npm test + +# ---- runtime stage ---- +FROM node:22-slim +ENV NODE_ENV=production +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/server/dist ./server/dist + +ENV PORT=3000 +ENV DATA_DIR=/data +VOLUME /data +EXPOSE 3000 + +CMD ["node", "server/dist/index.js"] +``` + +Note: `better-sqlite3` and `argon2` ship prebuilt binaries for linux-x64/arm64 glibc, so `node:22-slim` normally needs no compile step; the build tools are insurance. If you later add the frontend (plan 2), the web build joins the build stage and `web/dist` joins the runtime image. + +- [ ] **Step 3: Create `docker-compose.yml`** + +```yaml +services: + record-shop: + build: . + ports: + - "3000:3000" + volumes: + - ./data:/data + restart: unless-stopped +``` + +- [ ] **Step 4: Replace `README.md`** + +````markdown +# record-shop + +Easily manage a physical collection of music. + +Scan barcodes with your phone to catalogue vinyl, CDs and cassettes, pull +metadata from Discogs, and see what you have (and haven't) ripped into your +Subsonic-compatible music server (Navidrome, Gonic, Airsonic, LMS, …). + +## Quickstart + +```bash +docker compose up -d +``` + +Open , create the admin account, then add your Discogs +token and (optionally) your Subsonic server details in Settings. + +- Data lives in `./data` (SQLite database + cached cover art). +- Configure via env if you prefer: `PORT`, `DATA_DIR`. + +## Development + +```bash +npm install +npm test # vitest +npm run dev:server +``` + +Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md` +```` + +- [ ] **Step 5: Verify full build pipeline locally** + +Run: `npm test && npm run typecheck && npm run build` +Expected: all tests pass, no type errors, `server/dist/index.js` produced. + +Run: `node server/dist/index.js &` then `curl -s http://localhost:3000/api/health` — expect `{"ok":true}`; `kill %1`. + +- [ ] **Step 6: Build and verify the container** + +Run: `docker build -t record-shop .` +Expected: image builds; tests pass inside the build stage. + +Run: +```bash +docker run --rm -d --name rs-test -p 3001:3000 record-shop +sleep 2 +curl -s http://localhost:3001/api/health +curl -s http://localhost:3001/api/setup +docker stop rs-test +``` +Expected: `{"ok":true}` and `{"needed":true}`. + +- [ ] **Step 7: Verify compose path** + +Run: `docker compose up -d && sleep 2 && curl -s http://localhost:3000/api/setup && docker compose down` +Expected: `{"needed":true}`; container restarts cleanly. + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "feat: docker deployment with compose quickstart" +``` + +--- + +## Backend API summary (reference for plan 2) + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/api/health` | `{ok: true}` | +| GET | `/api/setup` | `{needed}` | +| POST | `/api/setup` | first admin; sets session cookie | +| POST | `/api/login` · `/api/logout` | cookie `rs_session` | +| GET | `/api/me` | `{user: {id, username, isAdmin}}` | +| GET/POST/DELETE | `/api/users[...]` | admin only | +| GET/PUT | `/api/settings` | `discogsToken`, `subsonicUrl/Username/Password`; empty string clears | +| GET | `/api/lookup/barcode/:code` | `{candidates}` · 404 `not_found` · 409 `no_discogs_token` | +| GET | `/api/lookup/search?q=&format=` | `{candidates}` | +| GET | `/api/lookup/release/:id` | `{release, duplicate, ripMatch, matchCandidates}` | +| GET | `/api/collection?format=&ripped=&q=` | `{items, counts}` | +| POST | `/api/collection` | `{releaseId, barcode?, matchAlbumId?}` → item | +| GET/DELETE | `/api/collection/:id` | item JSON | +| PATCH | `/api/collection/:id/rip` | `{ripped: true\|false\|null}` | +| POST | `/api/collection/:id/match` | `{albumId: number\|null}` | +| GET/POST | `/api/library/sync` | state · 202 start | +| GET | `/api/library/albums?q=` | for re-match UI | + +Item JSON: `{id, discogsReleaseId, title, artist, year, formats[], genres[], labels[], tracklist[], catno, country, artworkUrl, barcodes[], dateAdded, ripOverride, ripStatus}`. + +## What plan 2 covers (not in this plan) + +- React SPA (Vite + Tailwind), PWA manifest/service worker +- Barcode scanner (BarcodeDetector + ZXing fallback), scan flow state machine +- Library grid + filters, item detail, add/search, settings UI +- `npm run dev` with concurrently, updated Dockerfile web build