1
0
Files
record-shop/docs/superpowers/plans/2026-09-03-wave1.md

100 KiB

Wave 1 Implementation Plan (plan 3)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship Wave 1: schema versioning + live migration, built-in album player with last-played tracking, loan tracking, rip queue, stats wall, and JSON export + SQLite backups.

Architecture: Extends the existing Fastify/SQLite backend (server/) and React SPA (web/) without changing the frozen plan-1 API. New routes: stream/album proxy, loans, stats, export/backup. First schema migration via app_meta.schema_version. Player is a global context above the router so audio survives navigation.

Tech Stack: unchanged (Fastify 5, better-sqlite3, React 19, Vite, Vitest). Spec: docs/superpowers/specs/2026-09-03-wave1-hygiene-listening-design.md.

Prerequisites: main (post plan 2), 147/147 tests. Branch: wave1-hygiene-listening. Node >= 20.

Stamping refinement vs spec: Subsonic's recent-list entries don't reliably carry a played timestamp. last_played_at is therefore stamped from two sources: (1) sync, when an album entry reports one (played/playedAt field, OpenSubsonic-style — opportunistic, null-safe), and (2) the built-in player, via POST /api/album/:subsonicId/played on playback start. UX is identical.


File structure (plan 3)

server/src/
├── db.ts                  # MODIFY: versioned migrate(), loans table, last_played_at
├── config.ts              # MODIFY: backupsDir
├── subsonic.ts            # MODIFY: getAlbum(), getRecentAlbums(), public url()
├── sync.ts                # MODIFY: stamp last_played_at
├── ripstatus.ts           # MODIFY: findMatchedAlbum()
├── routes/
│   ├── collectionRoutes.ts # MODIFY: detail gains matchedAlbum, onLoan filter
│   ├── streamRoutes.ts     # NEW: /api/stream/:songId, /api/album/:id/tracks, /api/album/:id/played
│   ├── loanRoutes.ts       # NEW: loans CRUD
│   ├── statsRoutes.ts      # NEW: /api/stats
│   └── dataRoutes.ts       # NEW: /api/export, /api/backup, /api/backups
└── test/ (new: migrate.test.ts, loans.test.ts, stats.test.ts, data.test.ts, stream.test.ts; modified: subsonic.test.ts, sync.test.ts, collection.test.ts)
web/src/
├── types.ts               # MODIFY: Track, Stats, Loan, Backups, matchedAlbum
├── api.ts                 # MODIFY: new methods
├── player/PlayerContext.tsx # NEW: global player state + <audio>
├── player/MiniBar.tsx     # NEW: mini-bar + expanded panel
├── pages/ItemPage.tsx     # MODIFY: Play, last played, loan section
├── pages/LibraryPage.tsx  # MODIFY: header links, On loan chip
├── pages/QueuePage.tsx    # NEW
├── pages/StatsPage.tsx    # NEW
├── pages/SettingsPage.tsx # MODIFY: Data section
├── App.tsx                # MODIFY: routes + PlayerProvider
└── test/ (new: player.test.tsx, queue.test.tsx, stats.test.tsx; modified: item, library, settings, api tests)

Task 1: Schema versioning — migration v2

Files:

  • Modify: server/src/db.ts, server/src/config.ts

  • Test: server/test/migrate.test.ts

  • Step 1: Write the failing test server/test/migrate.test.ts

import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { openDatabase } from '../src/db.js'

/** Builds a pre-v2 database (plan-1 schema, no version row, no new columns). */
function legacyDb(): Database.Database {
  const db = new Database(':memory:')
  db.pragma('journal_mode = WAL')
  db.pragma('foreign_keys = ON')
  db.exec(`
    CREATE TABLE app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
    CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')));
    CREATE TABLE sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL);
    CREATE TABLE settings (user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discogs_token TEXT, subsonic_url TEXT, subsonic_username TEXT, subsonic_password TEXT);
    CREATE TABLE collection_items (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, discogs_release_id INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, year INTEGER, formats TEXT NOT NULL DEFAULT '[]', genres TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', tracklist TEXT NOT NULL DEFAULT '[]', catno TEXT, country TEXT, cover_url TEXT, local_artwork_path TEXT, barcodes TEXT NOT NULL DEFAULT '[]', rip_override INTEGER, date_added TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, discogs_release_id));
    CREATE TABLE digital_albums (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subsonic_id TEXT NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, UNIQUE (user_id, subsonic_id));
    CREATE TABLE match_links (user_id INTEGER NOT NULL, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, PRIMARY KEY (user_id, item_id));
    INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1);
    INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (1, 1, 'a1', 'Motion', 'The Cinematic Orchestra');
  `)
  return db
}

function columns(db: Database.Database, table: string): string[] {
  return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name)
}

describe('schema migration v2', () => {
  it('migrates a legacy database: loans table, last_played_at, version row', () => {
    const db = legacyDb()
    // run the exported upgrade path against the legacy db
    migrateUpgrades(db)
    expect(columns(db, 'digital_albums')).toContain('last_played_at')
    expect(columns(db, 'loans')).toEqual([
      'id',
      'user_id',
      'item_id',
      'borrower',
      'lent_at',
      'returned_at',
    ])
    const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
    expect(version).toEqual({ value: '2' })
    // existing data survives
    expect(db.prepare('SELECT title FROM digital_albums').get()).toEqual({ title: 'Motion' })
  })

  it('openDatabase creates a fresh v2 database directly', () => {
    const db = openDatabase(':memory:')
    expect(columns(db, 'digital_albums')).toContain('last_played_at')
    expect(columns(db, 'loans')).toContain('borrower')
    const version = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get()
    expect(version).toEqual({ value: '2' })
  })

  it('migrations are idempotent', () => {
    const db = openDatabase(':memory:')
    expect(() => migrateUpgrades(db)).not.toThrow()
    expect(
      (db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as { value: string }).value
    ).toBe('2')
  })

  let migrateUpgrades: (db: Database.Database) => void
  beforeEach(() => {
    // bound below after import — keeps the legacy-db helper readable
    ;({ migrateUpgrades } = require('../src/db.js'))
  })
})

Note: the project is ESM — replace the require trick with a normal top-level import: import { openDatabase, migrateUpgrades } from '../src/db.js' and drop the beforeEach binding. The helper name must match what db.ts exports.

  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/migrate.test.ts Expected: FAIL — migrateUpgrades is not exported.

  • Step 3: Implement — modify server/src/db.ts

The current migrate(db) execs one big schema string. Restructure: keep that string as BASE_SCHEMA (rename the exec target; content unchanged — v1 schema exactly), add the versioned upgrade path, and export migrateUpgrades:

function getSchemaVersion(db: DB): number {
  const row = db.prepare("SELECT value FROM app_meta WHERE key = 'schema_version'").get() as
    | { value: string }
    | undefined
  return row ? Number(row.value) : 0
}

function setSchemaVersion(db: DB, version: number): void {
  db.prepare(
    `INSERT INTO app_meta (key, value) VALUES ('schema_version', ?)
     ON CONFLICT(key) DO UPDATE SET value = excluded.value`
  ).run(String(version))
}

/** Upgrade steps for databases created before schema versioning existed. */
export function migrateUpgrades(db: DB): void {
  const version = getSchemaVersion(db)
  if (version < 2) {
    db.exec(`
      ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT;
      CREATE TABLE IF NOT EXISTS loans (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
        borrower TEXT NOT NULL,
        lent_at TEXT NOT NULL DEFAULT (datetime('now')),
        returned_at TEXT
      );
    `)
    setSchemaVersion(db, 2)
  }
}

export function migrate(db: DB): void {
  db.exec(BASE_SCHEMA)
  migrateUpgrades(db)
}

(migrate is called from openDatabase as before; BASE_SCHEMA is the existing DDL string, unchanged.)

  • Step 4: Add backupsDir to server/src/config.ts — extend Config with backupsDir: string and in loadConfig:
  const backupsDir = path.join(dataDir, 'backups')
  mkdirSync(backupsDir, { recursive: true })

(return backupsDir in the object; add backupsDir: ':memory:'-style literal to server/test/helpers.ts testConfig and the inline config in server/test/app.test.ts — real temp dir like artworkDir: mkdtempSync(path.join(tmpdir(), 'rs-bak-')).)

  • Step 5: Run tests

Run: npx vitest run server/test/migrate.test.ts && npm test && npm run typecheck Expected: ALL PASS (147 + 3 new = 150). Any test constructing a raw config literal needs backupsDir added — fix those (app.test.ts, helpers.ts).

  • Step 6: Commit
git add -A && git commit -m "feat: schema versioning with v2 migration (loans, last_played_at)"

Task 2: Subsonic client — getAlbum, getRecentAlbums, public url()

Files:

  • Modify: server/src/subsonic.ts

  • Test: server/test/subsonic.test.ts (append)

  • Step 1: Append failing tests to server/test/subsonic.test.ts

  it('url() builds a raw endpoint URL with auth params', () => {
    const c = new SubsonicClient({
      url: 'http://navidrome.local',
      username: 'sam',
      password: 'pass',
      fetchImpl: (async () => subsonicResponse({})) as typeof fetch,
    })
    const u = new URL(c.url('stream', { id: 'song-9' }))
    expect(u.pathname).toBe('/rest/stream')
    expect(u.searchParams.get('id')).toBe('song-9')
    expect(u.searchParams.get('u')).toBe('sam')
    expect(u.searchParams.get('f')).toBe('json')
  })

  it('getAlbum returns ordered tracks', async () => {
    const c = new SubsonicClient({
      url: 'http://x',
      username: 'sam',
      password: 'pass',
      fetchImpl: (async () =>
        subsonicResponse({
          'subsonic-response': {
            status: 'ok',
            album: {
              id: 'alb-1',
              name: 'Motion',
              artist: 'The Cinematic Orchestra',
              song: [
                { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
                { id: 's1', title: 'Overture', duration: 200, track: 1 },
              ],
            },
          },
        })) as typeof fetch,
    })
    const album = await c.getAlbum('alb-1')
    expect(album).toEqual({
      id: 'alb-1',
      title: 'Motion',
      artist: 'The Cinematic Orchestra',
      tracks: [
        { id: 's1', title: 'Overture', duration: 200, track: 1 },
        { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
      ],
    })
  })

  it('getRecentAlbums returns the recent list raw', async () => {
    const c = new SubsonicClient({
      url: 'http://x',
      username: 'sam',
      password: 'pass',
      fetchImpl: (async () =>
        subsonicResponse({
          'subsonic-response': {
            status: 'ok',
            albumList2: {
              album: [
                { id: 'a1', name: 'Motion', artist: 'TCO', played: '2026-09-01T10:00:00Z' },
                { id: 'a2', name: 'Blue Lines', artist: 'Massive Attack' },
              ],
            },
          },
        })) as typeof fetch,
    })
    const recent = await c.getRecentAlbums(500)
    expect(recent).toEqual([
      { id: 'a1', title: 'Motion', artist: 'TCO', playedAt: '2026-09-01T10:00:00Z' },
      { id: 'a2', title: 'Blue Lines', artist: 'Massive Attack', playedAt: null },
    ])
  })
  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/subsonic.test.ts Expected: FAIL — url/getAlbum/getRecentAlbums not implemented.

  • Step 3: Implement in server/src/subsonic.ts — add to the class:
  /** Raw endpoint URL with auth params — for streaming passthrough. */
  url(endpoint: string, params: Record<string, string> = {}): string {
    const u = new URL(`${this.base}/rest/${endpoint}`)
    const search = { ...this.authParams(), ...params }
    for (const [k, v] of Object.entries(search)) u.searchParams.set(k, v)
    return u.toString()
  }

  async getAlbum(albumId: string): Promise<{ id: string; title: string; artist: string; tracks: { id: string; title: string; duration: number | null; track: number | null }[] }> {
    const envelope = await this.request('getAlbum', { id: albumId })
    const album = envelope.album ?? {}
    const songs: any[] = album.song ?? []
    const tracks = songs
      .map((s) => ({
        id: String(s.id),
        title: String(s.title ?? ''),
        duration: Number.isFinite(Number(s.duration)) ? Number(s.duration) : null,
        track: Number.isInteger(Number(s.track)) ? Number(s.track) : null,
      }))
      .sort((a, b) => (a.track ?? 9999) - (b.track ?? 9999))
    return { id: String(album.id ?? albumId), title: album.name ?? album.title ?? '', artist: album.artist ?? '', tracks }
  }

  async getRecentAlbums(size = 500): Promise<{ id: string; title: string; artist: string; playedAt: string | null }[]> {
    const envelope = await this.request('getAlbumList2', { type: 'recent', size: String(size) })
    const list: any[] = envelope.albumList2?.album ?? []
    return list.map((a) => ({
      id: String(a.id),
      title: a.name ?? a.title ?? '',
      artist: a.artist ?? '',
      playedAt: typeof a.played === 'string' ? a.played : typeof a.playedAt === 'string' ? a.playedAt : null,
    }))
  }
  • Step 4: Run tests

Run: npx vitest run server/test/subsonic.test.ts && npm test && npm run typecheck Expected: ALL PASS (150 + 3 new = 153).

  • Step 5: Commit
git add -A && git commit -m "feat: subsonic getAlbum/getRecentAlbums and stream url builder"

Task 3: Sync stamps last_played_at + played endpoint

Files:

  • Modify: server/src/sync.ts, server/src/routes/streamRoutes.ts (created in this task), server/src/app.ts

  • Test: server/test/sync.test.ts (new — move existing sync tests? No: library.test.ts holds sync tests; create server/test/lastplayed.test.ts instead)

  • Step 1: Write the failing test server/test/lastplayed.test.ts

import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'

function stubWithRecent(recent: { id: number; name: string; artist: string; played?: string }[]): typeof fetch {
  return (async (input: any) => {
    const url = new URL(String(input))
    if (url.pathname.endsWith('/rest/ping')) {
      return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      })
    }
    if (url.pathname.endsWith('/rest/getAlbumList2')) {
      const type = url.searchParams.get('type')
      const body =
        type === 'recent'
          ? { 'subsonic-response': { status: 'ok', albumList2: { album: recent } } }
          : { 'subsonic-response': { status: 'ok', albumList2: { album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }] } } }
      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
    }
    return new Response('nope', { status: 404 })
  }) as typeof fetch
}

async function waitForDone(app: any, cookie: string, timeoutMs = 2000) {
  const start = Date.now()
  while (Date.now() - start < timeoutMs) {
    const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json()
    if (state.status !== 'running') return state
    await new Promise((r) => setTimeout(r, 10))
  }
  throw new Error('sync did not finish')
}

describe('last_played_at stamping', () => {
  it('sync stamps played timestamps from the recent list', async () => {
    const app = await buildTestApp(
      stubWithRecent([{ id: 1, name: 'Album 1', artist: 'Artist 0', played: '2026-09-01T10:00:00Z' }])
    )
    const cookie = await setupAdmin(app)
    await app.inject({
      method: 'PUT',
      url: '/api/settings',
      ...auth(cookie),
      payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
    })
    await waitForDone(app, cookie)
    const row = app.db
      .prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = '1'")
      .get() as { last_played_at: string | null }
    expect(row.last_played_at).toBe('2026-09-01T10:00:00Z')
    await app.close()
  })

  it('played endpoint stamps now and returns 404 for unknown albums', async () => {
    const app = await buildTestApp()
    const cookie = await setupAdmin(app)
    await app.inject({
      method: 'POST',
      url: '/api/library/albums/test-seed',
      ...auth(cookie),
      payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] },
    })
    const before = (
      app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
        last_played_at: string | null
      }
    ).last_played_at
    expect(before).toBeNull()

    const res = await app.inject({ method: 'POST', url: '/api/album/a1/played', ...auth(cookie) })
    expect(res.statusCode).toBe(200)
    const after = (
      app.db.prepare("SELECT last_played_at FROM digital_albums WHERE subsonic_id = 'a1'").get() as {
        last_played_at: string | null
      }
    ).last_played_at
    expect(after).toBeTruthy()

    const missing = await app.inject({ method: 'POST', url: '/api/album/zzz/played', ...auth(cookie) })
    expect(missing.statusCode).toBe(404)
    await app.close()
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/lastplayed.test.ts Expected: FAIL — played endpoint 404; sync doesn't stamp.

  • Step 3: Modify server/src/sync.ts run() — after the existing stale-deletion loop, inside the same transaction is not required (best-effort stamping):
      // best-effort: stamp played timestamps reported by the server
      try {
        const recent = await client.getRecentAlbums(500)
        const stamp = this.db.prepare(
          'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
        )
        for (const r of recent) {
          if (r.playedAt) stamp.run(r.playedAt, userId, r.id)
        }
      } catch {
        // recency stamping is optional
      }
  • Step 4: Create server/src/routes/streamRoutes.ts — full file:
import { FastifyInstance } from 'fastify'
import { SubsonicClient, SubsonicError } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'

function clientFor(request: any): SubsonicClient | null {
  const s = getSettings(request.server.db, request.user.id)
  if (!subsonicConfigComplete(s)) return null
  return new SubsonicClient({
    url: s.subsonic_url as string,
    username: s.subsonic_username as string,
    password: s.subsonic_password as string,
    fetchImpl: request.server.fetchImpl,
  })
}

export async function registerStreamRoutes(app: FastifyInstance): Promise<void> {
  app.get('/api/stream/:songId', { preHandler: [requireAuth] }, async (request, reply) => {
    const client = clientFor(request)
    if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
    const songId = (request.params as { songId: string }).songId
    const range = request.headers.range
    let upstream: Response
    try {
      upstream = await request.server.fetchImpl(client.url('stream', { id: songId }), {
        headers: range ? { Range: range } : {},
      })
    } catch {
      return reply.code(502).send({ error: 'stream_unavailable' })
    }
    if (!upstream.ok && upstream.status !== 206) {
      return reply.code(502).send({ error: 'stream_unavailable' })
    }
    const headers: Record<string, string> = {}
    for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
      const v = upstream.headers.get(h)
      if (v) headers[h] = v
    }
    if (!upstream.headers.get('accept-ranges')) headers['accept-ranges'] = 'bytes'
    return reply.code(upstream.status).headers(headers).send(upstream.body)
  })

  app.get('/api/album/:subsonicId/tracks', { preHandler: [requireAuth] }, async (request, reply) => {
    const client = clientFor(request)
    if (!client) return reply.code(409).send({ error: 'no_subsonic_config' })
    try {
      const album = await client.getAlbum((request.params as { subsonicId: string }).subsonicId)
      return album
    } catch (err) {
      if (err instanceof SubsonicError) return reply.code(502).send({ error: 'subsonic_error', detail: err.message })
      return reply.code(502).send({ error: 'subsonic_unreachable' })
    }
  })

  app.post('/api/album/:subsonicId/played', { preHandler: [requireAuth] }, async (request, reply) => {
    const subsonicId = (request.params as { subsonicId: string }).subsonicId
    const info = request.server.db
      .prepare(
        'UPDATE digital_albums SET last_played_at = ? WHERE user_id = ? AND subsonic_id = ?'
      )
      .run(new Date().toISOString(), request.user.id, subsonicId)
    if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
    return { ok: true }
  })
}
  • Step 5: Register in server/src/app.ts — import + await registerStreamRoutes(app) after registerLibraryRoutes(app).

  • Step 6: Run tests

Run: npx vitest run server/test/lastplayed.test.ts && npm test && npm run typecheck Expected: ALL PASS (153 + 2 new = 155).

  • Step 7: Commit
git add -A && git commit -m "feat: last-played stamping via sync and played endpoint, stream/album proxy routes"

Task 4: Item detail gains matchedAlbum; list gains onLoan filter

Files:

  • Modify: server/src/ripstatus.ts, server/src/routes/collectionRoutes.ts

  • Test: server/test/ripstatus.test.ts (append), server/test/collection.test.ts (append)

  • Step 1: Append failing test to server/test/ripstatus.test.ts (the file has a db fixture with users/items helpers — reuse addAlbum):

import { findMatchedAlbum } from '../src/ripstatus.js'

describe('findMatchedAlbum', () => {
  it('returns the match-linked album', () => {
    addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
    db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
    const m = findMatchedAlbum(db, 1, 10)
    expect(m).toMatchObject({ subsonicId: 'sub-1', lastPlayedAt: null })
  })

  it('falls back to the confident fuzzy match', () => {
    addAlbum(2, 'Motion!', 'Cinematic Orchestra')
    const m = findMatchedAlbum(db, 1, 10)
    expect(m).toMatchObject({ subsonicId: 'sub-2' })
  })

  it('returns null when nothing matches or the item is missing', () => {
    expect(findMatchedAlbum(db, 1, 10)).toBeNull()
    expect(findMatchedAlbum(db, 1, 9999)).toBeNull()
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/ripstatus.test.ts Expected: FAIL — findMatchedAlbum not exported.

  • Step 3: Implement findMatchedAlbum in server/src/ripstatus.ts (after resolveRipStatus):
export interface MatchedAlbum {
  id: number
  subsonicId: string
  lastPlayedAt: string | null
}

/** Album behind an item's rip match — link wins, else confident fuzzy match. Independent of rip_override. */
export function findMatchedAlbum(db: DB, userId: number, itemId: number): MatchedAlbum | null {
  const item = db
    .prepare('SELECT id, title, artist FROM collection_items WHERE id = ? AND user_id = ?')
    .get(itemId, userId) as { id: number; title: string; artist: string } | undefined
  if (!item) return null

  const link = db
    .prepare(
      `SELECT da.id, da.subsonic_id, da.last_played_at FROM match_links ml
       JOIN digital_albums da ON da.id = ml.album_id WHERE ml.item_id = ?`
    )
    .get(itemId) as { id: number; subsonic_id: string; last_played_at: string | null } | undefined
  if (link) {
    return { id: link.id, subsonicId: link.subsonic_id, lastPlayedAt: link.last_played_at }
  }

  const album = db
    .prepare(
      `SELECT id, subsonic_id, last_played_at FROM digital_albums WHERE user_id = ?`
    )
    .all(userId) as { id: number; subsonic_id: string; last_played_at: string | null; title: string; artist: string }[]
  const match = album.find((a) => isConfidentMatch(item, a))
  return match
    ? { id: match.id, subsonicId: match.subsonic_id, lastPlayedAt: match.last_played_at }
    : null
}
  • Step 4: Append failing tests to server/test/collection.test.ts (reuse the file's appWithToken, discogsStub, auth helpers; use the test-seed route for albums):
  it('detail includes matchedAlbum (null when unmatched)', async () => {
    const { app, cookie } = await appWithToken()
    const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
    const id = added.json().id as number
    const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
    expect(detail.json().matchedAlbum).toBeNull()

    await app.inject({
      method: 'POST',
      url: '/api/library/albums/test-seed',
      ...auth(cookie),
      payload: { albums: [{ subsonicId: 'alb-9', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
    })
    const again = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
    expect(again.json().matchedAlbum).toMatchObject({ subsonicId: 'alb-9', lastPlayedAt: null })
    await app.close()
  })

  it('list supports onLoan=true filter', async () => {
    const { app, cookie } = await appWithToken()
    const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
    const id = added.json().id as number
    await app.inject({ method: 'POST', url: `/api/collection/${id}/loan`, ...auth(cookie), payload: { borrower: 'Bob' } })

    const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
    expect(all.json().items).toHaveLength(1)
    const onLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=true', ...auth(cookie) })
    expect(onLoan.json().items).toHaveLength(1)
    const notOnLoan = await app.inject({ method: 'GET', url: '/api/collection?onLoan=false', ...auth(cookie) })
    expect(notOnLoan.json().items).toHaveLength(0)
    await app.close()
  })

(Note: the onLoan test depends on Task 5's loan route — it is a known-red handoff inside this task; Task 5 turns it green. Verify everything else passes here.)

  • Step 5: Implement — in server/src/routes/collectionRoutes.ts:

Detail route: after rowToItem, attach matchedAlbum:

import { resolveRipStatus, findMatchedAlbum } from '../ripstatus.js'
    const row = getItem(db, request.user.id, Number((request.params as { id: string }).id))
    if (!row) return reply.code(404).send({ error: 'not_found' })
    return { ...rowToItem(db, row), matchedAlbum: findMatchedAlbum(db, request.user.id, row.id) }

List route: extend the query destructure with onLoan and filter before returning (after the q filter):

    if (onLoan === 'true' || onLoan === 'false') {
      const want = onLoan === 'true'
      filtered = filtered.filter((i) => {
        const has = !!db
          .prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
          .get(i.id)
        return has === want
      })
    }

and the query type gains onLoan?: string.

  • Step 6: Run tests

Run: npx vitest run server/test/ripstatus.test.ts server/test/collection.test.ts && npm test && npm run typecheck Expected: ALL PASS except the onLoan handoff test (160 total = 155 + 5 new; 1 known-red).

  • Step 7: Commit
git add -A && git commit -m "feat: matchedAlbum on item detail, onLoan collection filter"

Task 5: Loan routes

Files:

  • Create: server/src/routes/loanRoutes.ts

  • Test: server/test/loans.test.ts

  • Modify: server/src/app.ts

  • Step 1: Write the failing test server/test/loans.test.ts

import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
import { discogsReleaseFixture } from './fixtures.js'

const discogsStub = (async (input: any) =>
  String(input).includes('/releases/1001')
    ? new Response(JSON.stringify(discogsReleaseFixture), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      })
    : new Response('nope', { status: 404 })) as typeof fetch

async function appWithItem() {
  const app = await buildTestApp(discogsStub)
  const cookie = await setupAdmin(app)
  await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 't' } })
  const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
  return { app, cookie, itemId: added.json().id as number }
}

describe('loans', () => {
  it('lend, list active, return', async () => {
    const { app, cookie, itemId } = await appWithItem()

    const lend = await app.inject({
      method: 'POST',
      url: `/api/collection/${itemId}/loan`,
      ...auth(cookie),
      payload: { borrower: 'Bob' },
    })
    expect(lend.statusCode).toBe(200)
    const loan = lend.json()
    expect(loan).toMatchObject({ itemId, borrower: 'Bob', returnedAt: null })

    const dup = await app.inject({
      method: 'POST',
      url: `/api/collection/${itemId}/loan`,
      ...auth(cookie),
      payload: { borrower: 'Eve' },
    })
    expect(dup.statusCode).toBe(409)
    expect(dup.json()).toEqual({ error: 'already_on_loan' })

    const list = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
    expect(list.json().active).toHaveLength(1)
    expect(list.json().history).toHaveLength(0)

    const ret = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
    expect(ret.statusCode).toBe(200)
    const after = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
    expect(after.json().active).toHaveLength(0)
    expect(after.json().history).toHaveLength(1)

    const again = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
    expect(again.statusCode).toBe(404)
    await app.close()
  })

  it('validates borrower and ownership', async () => {
    const { app, cookie, itemId } = await appWithItem()
    const empty = await app.inject({
      method: 'POST',
      url: `/api/collection/${itemId}/loan`,
      ...auth(cookie),
      payload: { borrower: '  ' },
    })
    expect(empty.statusCode).toBe(400)

    const missing = await app.inject({
      method: 'POST',
      url: `/api/collection/9999/loan`,
      ...auth(cookie),
      payload: { borrower: 'Bob' },
    })
    expect(missing.statusCode).toBe(404)
    await app.close()
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/loans.test.ts Expected: FAIL — 404s on loan routes.

  • Step 3: Create server/src/routes/loanRoutes.ts — full file:
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'

interface LoanRow {
  id: number
  user_id: number
  item_id: number
  borrower: string
  lent_at: string
  returned_at: string | null
}

function toLoan(l: LoanRow) {
  return { id: l.id, itemId: l.item_id, borrower: l.borrower, lentAt: l.lent_at, returnedAt: l.returned_at }
}

export async function registerLoanRoutes(app: FastifyInstance): Promise<void> {
  app.post('/api/collection/:id/loan', { preHandler: [requireAuth] }, async (request, reply) => {
    const db = request.server.db
    const userId = request.user.id
    const id = Number((request.params as { id: string }).id)
    const item = db
      .prepare('SELECT id FROM collection_items WHERE id = ? AND user_id = ?')
      .get(id, userId)
    if (!item) return reply.code(404).send({ error: 'not_found' })
    const { borrower } = (request.body ?? {}) as { borrower?: string }
    if (typeof borrower !== 'string' || borrower.trim() === '') {
      return reply.code(400).send({ error: 'invalid_input', detail: 'borrower is required' })
    }
    const active = db
      .prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
      .get(id)
    if (active) return reply.code(409).send({ error: 'already_on_loan' })
    const info = db
      .prepare('INSERT INTO loans (user_id, item_id, borrower) VALUES (?, ?, ?)')
      .run(userId, id, borrower.trim())
    const loan = db.prepare('SELECT * FROM loans WHERE id = ?').get(info.lastInsertRowid) as LoanRow
    return reply.code(200).send(toLoan(loan))
  })

  app.get('/api/loans', { preHandler: [requireAuth] }, async (request) => {
    const db = request.server.db
    const userId = request.user.id
    const active = db
      .prepare('SELECT * FROM loans WHERE user_id = ? AND returned_at IS NULL ORDER BY lent_at DESC')
      .all(userId) as LoanRow[]
    const history = db
      .prepare(
        'SELECT * FROM loans WHERE user_id = ? AND returned_at IS NOT NULL ORDER BY returned_at DESC LIMIT 50'
      )
      .all(userId) as LoanRow[]
    return { active: active.map(toLoan), history: history.map(toLoan) }
  })

  app.post('/api/loans/:id/return', { preHandler: [requireAuth] }, async (request, reply) => {
    const db = request.server.db
    const id = Number((request.params as { id: string }).id)
    const info = db
      .prepare("UPDATE loans SET returned_at = datetime('now') WHERE id = ? AND user_id = ? AND returned_at IS NULL")
      .run(id, request.user.id)
    if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
    return { ok: true }
  })
}
  • Step 4: Register in server/src/app.ts — import + await registerLoanRoutes(app) after registerStreamRoutes(app).

  • Step 5: Run tests

Run: npx vitest run server/test/loans.test.ts server/test/collection.test.ts && npm test && npm run typecheck Expected: ALL PASS (160 + 2 new = 162; the Task 4 onLoan handoff test now green).

  • Step 6: Commit
git add -A && git commit -m "feat: loan tracking routes"

Task 6: Stats route

Files:

  • Create: server/src/routes/statsRoutes.ts

  • Test: server/test/stats.test.ts

  • Modify: server/src/app.ts

  • Step 1: Write the failing test server/test/stats.test.ts

import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js'

function seedItems(db: ReturnType<typeof openDatabase>) {
  const ins = db.prepare(
    `INSERT INTO collection_items (user_id, discogs_release_id, title, artist, year, formats, genres, date_added, rip_override)
     VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)`
  )
  ins.run(1, 'Motion', 'The Cinematic Orchestra', 1999, JSON.stringify(['CD']), JSON.stringify(['Electronic']), '2026-08-01 10:00:00', null)
  ins.run(2, 'Blue Lines', 'Massive Attack', 1991, JSON.stringify(['Vinyl']), JSON.stringify(['Electronic']), '2026-08-15 10:00:00', 1)
  ins.run(3, 'Mezzanine', 'Massive Attack', 1998, JSON.stringify(['CD']), JSON.stringify(['Downtempo', 'Electronic']), '2026-08-20 10:00:00', null)
  ins.run(4, 'Dummy', 'Portishead', 1994, JSON.stringify(['CD', 'Album']), JSON.stringify(['Downtempo']), '2025-09-01 10:00:00', null)
  db.prepare("INSERT INTO loans (user_id, item_id, borrower) VALUES (1, 2, 'Bob')").run()
}

describe('GET /api/stats', () => {
  it('aggregates totals, formats, genres, artists, months, ratio, loans', async () => {
    const db = openDatabase(':memory:')
    seedItems(db)
    const app = await buildTestAppWithDb(db)
    const cookie = await setupAdmin(app)
    const res = await app.inject({ method: 'GET', url: '/api/stats', ...auth(cookie) })
    expect(res.statusCode).toBe(200)
    const s = res.json()
    expect(s.totals).toEqual({ items: 4, ripped: 2, notRipped: 2, onLoan: 1 })
    expect(s.ripRatio).toBeCloseTo(0.5)
    expect(s.formats).toEqual([
      { name: 'CD', count: 3 },
      { name: 'Vinyl', count: 1 },
      { name: 'Album', count: 1 },
    ])
    expect(s.topGenres.slice(0, 2)).toEqual([
      { name: 'Electronic', count: 3 },
      { name: 'Downtempo', count: 2 },
    ])
    expect(s.topArtists.slice(0, 2)).toEqual([
      { name: 'Massive Attack', count: 2 },
      { name: 'Portishead', count: 1 },
    ])
    const aug = s.addedByMonth.find((m: { month: string }) => m.month === '2026-08')
    expect(aug).toEqual({ month: '2026-08', count: 3 })
    await app.close()
  })
})

Note: rip counts in this fixture come from rip_override (Blue Lines override 1 = ripped; others auto → no digital albums → not_ripped). Motion and Mezzanine and Dummy are not_ripped → totals {4, 2 ripped? no...}. Recount: overrides — item 2 override 1 → ripped; items 1,3,4 override null + no albums → not_ripped. So ripped=1, notRipped=3, ratio 0.25. FIX the expected values: totals {items:4, ripped:1, notRipped:3, onLoan:1}, ripRatio 0.25, and topArtists Massive Attack 2 first. Use these corrected expectations in the final test.

  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/stats.test.ts Expected: FAIL — 404.

  • Step 3: Create server/src/routes/statsRoutes.ts — full file:
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'

function countBy(values: string[]): { name: string; count: number }[] {
  const map = new Map<string, number>()
  for (const v of values) {
    if (!v) continue
    map.set(v, (map.get(v) ?? 0) + 1)
  }
  return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
}

export async function registerStatsRoutes(app: FastifyInstance): Promise<void> {
  app.get('/api/stats', { preHandler: [requireAuth] }, async (request) => {
    const db = request.server.db
    const userId = request.user.id
    const items = db
      .prepare('SELECT id, title, artist, year, formats, genres, date_added, rip_override FROM collection_items WHERE user_id = ?')
      .all(userId) as {
      id: number
      title: string
      artist: string
      year: number | null
      formats: string
      genres: string
      date_added: string
      rip_override: number | null
    }[]

    const rippedCounts = { ripped: 0, notRipped: 0 }
    const formats: string[] = []
    const genres: string[] = []
    const months = new Map<string, number>()
    for (const item of items) {
      let ripped: boolean
      if (item.rip_override !== null && item.rip_override !== undefined) {
        ripped = item.rip_override === 1
      } else {
        ripped = !!db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(item.id) ||
          !!db
            .prepare('SELECT id FROM digital_albums WHERE user_id = ?')
            .all(userId)
            .some((a: any) => a.title === item.title && a.artist === item.artist)
      }
      ripped ? rippedCounts.ripped++ : rippedCounts.notRipped++
      for (const f of JSON.parse(item.formats) as string[]) formats.push(f)
      for (const g of JSON.parse(item.genres) as string[]) genres.push(g)
      const month = (item.date_added ?? '').slice(0, 7)
      if (/^\d{4}-\d{2}$/.test(month)) months.set(month, (months.get(month) ?? 0) + 1)
    }

    const onLoan = (
      db.prepare('SELECT COUNT(*) AS n FROM loans WHERE user_id = ? AND returned_at IS NULL').get(userId) as { n: number }
    ).n

    const addedByMonth: { month: string; count: number }[] = []
    const now = new Date()
    for (let i = 11; i >= 0; i--) {
      const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
      const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
      addedByMonth.push({ month: key, count: months.get(key) ?? 0 })
    }

    const artists = countBy(items.map((i) => i.artist)).slice(0, 10)
    const decades = countBy(items.map((i) => (i.year ? `${Math.floor(i.year / 10) * 10}s` : '')))

    return {
      totals: {
        items: items.length,
        ripped: rippedCounts.ripped,
        notRipped: rippedCounts.notRipped,
        onLoan,
      },
      ripRatio: items.length === 0 ? 0 : rippedCounts.ripped / items.length,
      formats: countBy(formats),
      decades,
      topGenres: countBy(genres).slice(0, 10),
      topArtists: artists,
      addedByMonth,
    }
  })
}

IMPORTANT consistency decision: the rip determination here reimplements resolution loosely (title/artist equality, no normalize). REUSE the real logic instead: import resolveRipStatusBatch from '../ripstatus.js' (plan-2 fix) and compute statuses for all items in one call:

    const statuses = resolveRipStatusBatch(db, userId, items)
    items.forEach((item, i) => { ripped = statuses[i] === 'ripped' })

Implement with the batch call (zip items+statuses); drop the inline fuzzy check. The test expectations stand as corrected above.

  • Step 4: Register in server/src/app.ts — import + await registerStatsRoutes(app) after registerLoanRoutes(app).

  • Step 5: Run tests

Run: npx vitest run server/test/stats.test.ts && npm test && npm run typecheck Expected: ALL PASS (162 + 1 new = 163).

  • Step 6: Commit
git add -A && git commit -m "feat: collection stats route"

Task 7: Export + backup routes

Files:

  • Create: server/src/routes/dataRoutes.ts

  • Test: server/test/data.test.ts

  • Modify: server/src/app.ts

  • Step 1: Write the failing test server/test/data.test.ts

import { describe, it, expect } from 'vitest'
import { readdirSync } from 'node:fs'
import path from 'node:path'
import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js'

async function appWithData() {
  const app = await buildTestApp()
  const cookie = await setupAdmin(app)
  await app.inject({
    method: 'POST',
    url: '/api/library/albums/test-seed',
    ...auth(cookie),
    payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'TCO' }] },
  })
  await app.inject({
    method: 'POST',
    url: '/api/collection/1/loan',
    ...auth(cookie),
    payload: { borrower: 'Bob' },
  }).catch(() => {})
  return { app, cookie }
}

describe('export', () => {
  it('returns per-user data without secrets', async () => {
    const { app, cookie } = await appWithData()
    await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 'secret-token' } })
    const res = await app.inject({ method: 'GET', url: '/api/export', ...auth(cookie) })
    expect(res.statusCode).toBe(200)
    const body = res.json()
    expect(body.exportedAt).toBeTruthy()
    expect(body.items).toEqual([])
    expect(body.loans).toEqual([])
    expect(body.matchLinks).toEqual([])
    expect(JSON.stringify(body)).not.toContain('secret-token')
    await app.close()
  })
})

describe('backups', () => {
  it('non-admin cannot trigger backups', async () => {
    const app = await buildTestApp()
    const cookie = await setupAdmin(app)
    const bob = await loginAs(app, cookie, 'bob', 'bobpass123')
    const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(bob) })
    expect(res.statusCode).toBe(403)
    await app.close()
  })

  it('admin creates a backup file and lists it', async () => {
    const app = await buildTestApp()
    const cookie = await setupAdmin(app)
    const res = await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
    expect(res.statusCode).toBe(200)
    expect(res.json().file).toMatch(/record-shop-.*\.db$/)
    const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
    expect(list.json().backups).toHaveLength(1)
    expect(list.json().backups[0].file).toMatch(/\.db$/)
    await app.close()
  })

  it('prunes to the newest 7 backups', async () => {
    const app = await buildTestApp()
    const cookie = await setupAdmin(app)
    for (let i = 0; i < 9; i++) {
      await app.inject({ method: 'POST', url: '/api/backup', ...auth(cookie) })
    }
    const list = await app.inject({ method: 'GET', url: '/api/backups', ...auth(cookie) })
    expect(list.json().backups).toHaveLength(7)
    await app.close()
  })
})

Note: /api/collection/1/loan against a fresh app (no items) returns 404 — the .catch(() => {}) guard on inject is wrong (inject doesn't reject); just fire it without the catch and ignore the status, or drop that call — export asserts empty arrays anyway. Simplify: remove the loan call from appWithData.

  • Step 2: Run test to verify it fails

Run: npx vitest run server/test/data.test.ts Expected: FAIL — 404s.

  • Step 3: Create server/src/routes/dataRoutes.ts — full file:
import { FastifyInstance } from 'fastify'
import { readdirSync, statSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { requireAuth, requireAdmin } from './authRoutes.js'

function matchLinksFor(db: any, userId: number) {
  return db
    .prepare(
      `SELECT ml.item_id AS itemId, ml.album_id AS albumId FROM match_links ml WHERE ml.user_id = ?`
    )
    .all(userId)
}

function listBackups(dir: string): { file: string; sizeBytes: number; createdAt: string }[] {
  try {
    return readdirSync(dir)
      .filter((f) => f.endsWith('.db'))
      .map((file) => {
        const full = path.join(dir, file)
        const st = statSync(full)
        return { file, sizeBytes: st.size, createdAt: st.mtime.toISOString() }
      })
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
  } catch {
    return []
  }
}

export async function registerDataRoutes(app: FastifyInstance): Promise<void> {
  app.get('/api/export', { preHandler: [requireAuth] }, async (request) => {
    const db = request.server.db
    const userId = request.user.id
    const items = db
      .prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added')
      .all(userId)
    const loans = db.prepare('SELECT * FROM loans WHERE user_id = ?').all(userId)
    return {
      exportedAt: new Date().toISOString(),
      items,
      loans,
      matchLinks: matchLinksFor(db, userId),
    }
  })

  app.post('/api/backup', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
    const config = request.server.config
    const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19)
    const dest = path.join(config.backupsDir, `record-shop-${stamp}.db`)
    await request.server.db.backup(dest)
    // prune to newest 7
    const backups = listBackups(config.backupsDir)
    for (const old of backups.slice(7)) {
      try {
        unlinkSync(path.join(config.backupsDir, old.file))
      } catch {
        // best-effort prune
      }
    }
    return { file: path.basename(dest) }
  })

  app.get('/api/backups', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
    return { backups: listBackups(request.server.config.backupsDir) }
  })
}

Note: /api/export returns raw rows (snake_case, internal ids) — intentional: it's a backup artifact, not a UI payload. Tests assert shape only.

  • Step 4: Register in server/src/app.ts — import + await registerDataRoutes(app) after registerStatsRoutes(app).

  • Step 5: Run tests

Run: npx vitest run server/test/data.test.ts && npm test && npm run typecheck Expected: ALL PASS (163 + 4 new = 167).

  • Step 6: Commit
git add -A && git commit -m "feat: json export and sqlite backup endpoints"

Task 8: Web — types + api client additions

Files:

  • Modify: web/src/types.ts, web/src/api.ts

  • Test: web/test/api.test.ts (new, thin — URL/shape checks via fetch stub)

  • Step 1: Add types to web/src/types.ts (append):

export interface Track {
  id: string
  title: string
  duration: number | null
  track: number | null
}
export interface AlbumTracks {
  id: string
  title: string
  artist: string
  tracks: Track[]
}
export interface MatchedAlbum {
  id: number
  subsonicId: string
  lastPlayedAt: string | null
}
export interface Stats {
  totals: { items: number; ripped: number; notRipped: number; onLoan: number }
  ripRatio: number
  formats: { name: string; count: number }[]
  decades: { name: string; count: number }[]
  topGenres: { name: string; count: number }[]
  topArtists: { name: string; count: number }[]
  addedByMonth: { month: string; count: number }[]
}
export interface Loan {
  id: number
  itemId: number
  borrower: string
  lentAt: string
  returnedAt: string | null
}
export interface LoansResponse {
  active: Loan[]
  history: Loan[]
}
export interface BackupFile {
  file: string
  sizeBytes: number
  createdAt: string
}
  • Step 2: Modify web/src/api.ts — extend the imports (type { …, Track is not needed here }, add AlbumTracks, Loan, LoansResponse, Stats, BackupFile) and append methods to the api object:
  getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/api/album/${encodeURIComponent(subsonicId)}/tracks`),
  markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`),
  streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`,

  getStats: () => request<Stats>('/api/stats'),
  exportUrl: () => '/api/export',

  lendItem: (id: number, borrower: string) => post<Loan>(`/api/collection/${id}/loan`, { borrower }),
  getLoans: () => request<LoansResponse>('/api/loans'),
  returnLoan: (id: number) => post<{ ok: boolean }>(`/api/loans/${id}/return`),

  triggerBackup: () => post<{ file: string }>('/api/backup'),
  getBackups: () => request<{ backups: BackupFile[] }>('/api/backups'),

Also change getItem's return type: request<Item & { matchedAlbum: MatchedAlbum | null }> (or add a MatchedAlbum import and use ItemDetail alias — define export interface ItemDetail extends Item { matchedAlbum: MatchedAlbum | null } in types.ts and use it in api.ts).

Also listCollection params gain onLoan?: string:

    if (params.onLoan) usp.set('onLoan', params.onLoan)
  • Step 3: Write web/test/api.test.ts (thin — stub fetch, assert URLs and error mapping):
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { api, ApiError } from '../src/api.js'

const fetchMock = vi.fn()
beforeEach(() => vi.stubGlobal('fetch', fetchMock))
afterEach(() => vi.unstubAllGlobals())

function jsonOk(body: unknown) {
  return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
}

describe('api additions', () => {
  it('album tracks + played + stream url', async () => {
    fetchMock.mockReturnValue(jsonOk({ id: 'a1', title: 'Motion', artist: 'TCO', tracks: [] }))
    await api.getAlbumTracks('a1')
    expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/tracks', expect.anything())
    await api.markPlayed('a1')
    expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/played', expect.objectContaining({ method: 'POST' }))
    expect(api.streamUrl('s 1')).toBe('/api/stream/s%201')
  })

  it('stats, loans, backups urls', async () => {
    fetchMock.mockReturnValue(jsonOk({}))
    await api.getStats()
    expect(fetchMock).toHaveBeenCalledWith('/api/stats', expect.anything())
    await api.lendItem(5, 'Bob')
    expect(fetchMock).toHaveBeenCalledWith('/api/collection/5/loan', expect.objectContaining({ method: 'POST' }))
    await api.returnLoan(7)
    expect(fetchMock).toHaveBeenCalledWith('/api/loans/7/return', expect.objectContaining({ method: 'POST' }))
    await api.getBackups()
    expect(fetchMock).toHaveBeenCalledWith('/api/backups', expect.anything())
    expect(api.exportUrl()).toBe('/api/export')
  })

  it('surfaces subsonic errors as ApiError', async () => {
    fetchMock.mockReturnValue(
      Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
    )
    const err = await api.getAlbumTracks('a1').catch((e) => e)
    expect(err).toBeInstanceOf(ApiError)
    expect((err as ApiError).code).toBe('no_subsonic_config')
  })
})
  • Step 4: Run tests

Run: npx vitest run web/test/api.test.ts && npm test && npm run typecheck Expected: ALL PASS (167 + 3 new = 170). Existing page tests that mock the api module keep working (spread of actual).

  • Step 5: Commit
git add -A && git commit -m "feat: web api client additions for wave 1"

Task 9: Player context + reducer + audio element

Files:

  • Create: web/src/player/PlayerContext.tsx

  • Test: web/test/player.test.tsx

  • Step 1: Write the failing test web/test/player.test.tsx

import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js'
import type { Track } from '../src/types.js'

vi.mock('../src/api.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/api.js')>()
  return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})

import { api } from '../src/api.js'

const tracks: Track[] = [
  { id: 's1', title: 'Overture', duration: 200, track: 1 },
  { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]

function Probe() {
  const { state, load, toggle, next, prev, close } = usePlayer()
  return (
    <div>
      <div>phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}</div>
      <div>track:{state.tracks[state.index]?.title ?? 'none'}</div>
      <button type="button" onClick={() => load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>load</button>
      <button type="button" onClick={() => toggle()}>toggle</button>
      <button type="button" onClick={() => next()}>next</button>
      <button type="button" onClick={() => prev()}>prev</button>
      <button type="button" onClick={() => close()}>close</button>
    </div>
  )
}

function renderPlayer() {
  return render(
    <PlayerProvider>
      <Probe />
    </PlayerProvider>
  )
}

describe('PlayerProvider', () => {
  it('loads a queue, stamps played, starts at track 1', async () => {
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderPlayer()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
    expect(screen.getByText('track:Overture')).toBeTruthy()
    expect(api.getAlbumTracks).toHaveBeenCalledWith('a1')
    expect(api.markPlayed).toHaveBeenCalledWith('a1')
  })

  it('toggle pauses and resumes', async () => {
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderPlayer()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
    await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
    expect(screen.getByText('phase:paused')).toBeTruthy()
    await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
    expect(screen.getByText('phase:playing')).toBeTruthy()
  })

  it('next/prev move through the queue and stop at the edges', async () => {
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderPlayer()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
    await userEvent.click(screen.getByRole('button', { name: 'next' }))
    expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()
    await userEvent.click(screen.getByRole('button', { name: 'next' }))
    expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() // last track: no advance
    await userEvent.click(screen.getByRole('button', { name: 'prev' }))
    expect(screen.getByText('track:Overture')).toBeTruthy()
    await userEvent.click(screen.getByRole('button', { name: 'prev' }))
    expect(screen.getByText('track:Overture')).toBeTruthy() // first track: no rewind
  })

  it('close empties the player', async () => {
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderPlayer()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
    await userEvent.click(screen.getByRole('button', { name: 'close' }))
    expect(screen.getByText('phase:empty')).toBeTruthy()
  })

  it('audio error marks the track failed and skips to the next', async () => {
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderPlayer()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
    act(() => {
      document.querySelector('audio')!.dispatchEvent(new Event('error'))
    })
    await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy())
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run web/test/player.test.tsx Expected: FAIL — module not found.

  • Step 3: Create web/src/player/PlayerContext.tsx — full file:
import { createContext, useContext, useEffect, useReducer, useCallback, type ReactNode } from 'react'
import { api } from '../api.js'
import type { Track } from '../types.js'

export interface PlayerAlbum {
  id: string
  title: string
  artist: string
}

export interface PlayerState {
  album: PlayerAlbum | null
  tracks: Track[]
  index: number
  playing: boolean
  failed: number[]
}

type PlayerAction =
  | { type: 'LOAD'; album: PlayerAlbum; tracks: Track[] }
  | { type: 'TOGGLE' }
  | { type: 'NEXT' }
  | { type: 'PREV' }
  | { type: 'TRACK_ERROR' }
  | { type: 'CLOSE' }

const INITIAL: PlayerState = { album: null, tracks: [], index: 0, playing: false, failed: [] }

export function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
  switch (action.type) {
    case 'LOAD':
      return { album: action.album, tracks: action.tracks, index: 0, playing: action.tracks.length > 0, failed: [] }
    case 'TOGGLE':
      return { ...state, playing: !state.playing }
    case 'NEXT':
      return state.index < state.tracks.length - 1 ? { ...state, index: state.index + 1, playing: true } : { ...state, playing: false }
    case 'PREV':
      return state.index > 0 ? { ...state, index: state.index - 1, playing: true } : state
    case 'TRACK_ERROR':
      return {
        ...state,
        failed: state.failed.includes(state.index) ? state.failed : [...state.failed, state.index],
        ...(state.index < state.tracks.length - 1 ? { index: state.index + 1, playing: true } : { playing: false }),
      }
    case 'CLOSE':
      return INITIAL
  }
}

interface PlayerContextValue {
  state: PlayerState
  load: (album: PlayerAlbum) => Promise<void>
  toggle: () => void
  next: () => void
  prev: () => void
  close: () => void
}

const PlayerContext = createContext<PlayerContextValue | null>(null)

export function PlayerProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(playerReducer, INITIAL)

  const load = useCallback(async (album: PlayerAlbum) => {
    const data = await api.getAlbumTracks(album.id)
    dispatch({ type: 'LOAD', album: { id: data.id, title: data.title, artist: data.artist }, tracks: data.tracks })
    void api.markPlayed(album.id).catch(() => {})
  }, [])

  const audioRef = useRef<HTMLAudioElement | null>(null)
  const current = state.tracks[state.index]

  // keep the single audio element in sync with the reducer
  useEffect(() => {
    const audio = audioRef.current
    if (!audio) return
    if (!current) {
      audio.pause()
      audio.removeAttribute('src')
      return
    }
    const wanted = api.streamUrl(current.id)
    if (!audio.src.endsWith(wanted)) audio.src = wanted
    if (state.playing) void audio.play().catch(() => {})
    else audio.pause()
  }, [current, state.playing])

  const onEnded = useCallback(() => dispatch({ type: 'NEXT' }), [])
  const onError = useCallback(() => dispatch({ type: 'TRACK_ERROR' }), [])
  const toggle = useCallback(() => dispatch({ type: 'TOGGLE' }), [])
  const next = useCallback(() => dispatch({ type: 'NEXT' }), [])
  const prev = useCallback(() => dispatch({ type: 'PREV' }), [])
  const close = useCallback(() => dispatch({ type: 'CLOSE' }), [])

  return (
    <PlayerContext.Provider value={{ state, load, toggle, next, prev, close }}>
      {children}
      <audio ref={audioRef} onEnded={onEnded} onError={onError} preload="none" />
    </PlayerContext.Provider>
  )
}

export function usePlayer(): PlayerContextValue {
  const ctx = useContext(PlayerContext)
  if (!ctx) throw new Error('usePlayer outside PlayerProvider')
  return ctx
}

Add useRef to the react import. jsdom caveat: HTMLMediaElement.play is not implemented — vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() in the test file's top-level beforeEach (the scanner test already uses this pattern); also mock pause.

  • Step 4: Run tests

Run: npx vitest run web/test/player.test.tsx && npm test && npm run typecheck Expected: ALL PASS (170 + 5 new = 175).

  • Step 5: Commit
git add -A && git commit -m "feat: global player context with album queue and audio element"

Task 10: MiniBar + expanded player, wired into the shell

Files:

  • Create: web/src/player/MiniBar.tsx

  • Modify: web/src/App.tsx (wrap routes with PlayerProvider inside AuthProvider; render MiniBar inside the protected shell)

  • Test: web/test/minibar.test.tsx

  • Step 1: Write the failing test web/test/minibar.test.tsx

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js'
import MiniBar from '../src/player/MiniBar.js'
import type { Track } from '../src/types.js'

vi.mock('../src/api.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/api.js')>()
  return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'

const tracks: Track[] = [
  { id: 's1', title: 'Overture', duration: 200, track: 1 },
  { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]

function Loader() {
  const { load } = usePlayer()
  return (
    <button type="button" onClick={() => void load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>
      load
    </button>
  )
}

beforeEach(() => {
  vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
  vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
  vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
  vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
})

function renderBar() {
  return render(
    <PlayerProvider>
      <Loader />
      <MiniBar />
    </PlayerProvider>
  )
}

describe('MiniBar', () => {
  it('hidden when nothing is loaded, shows controls when playing', async () => {
    renderBar()
    expect(screen.queryByRole('button', { name: /play or pause/i })).toBeNull()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await waitFor(() => expect(screen.getByText('Motion')).toBeTruthy())
    expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
  })

  it('pause/resume works from the bar', async () => {
    renderBar()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    const pauseBtn = await screen.findByRole('button', { name: /pause/i })
    await userEvent.click(pauseBtn)
    expect(screen.getByRole('button', { name: /play/i })).toBeTruthy()
  })

  it('expands to the track list and closes', async () => {
    renderBar()
    await userEvent.click(screen.getByRole('button', { name: 'load' }))
    await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
    expect(screen.getByText('Theme de Yoyo')).toBeTruthy()
    expect(screen.getByText('Overture')).toBeTruthy()
    await userEvent.click(screen.getByRole('button', { name: /collapse/i }))
    expect(screen.queryByText('Theme de Yoyo')).toBeNull()
    await userEvent.click(screen.getByRole('button', { name: /close player/i }))
    await waitFor(() => expect(screen.queryByText('Motion')).toBeNull())
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run web/test/minibar.test.tsx Expected: FAIL — MiniBar not found.

  • Step 3: Create web/src/player/MiniBar.tsx — full file:
import { useState } from 'react'
import { usePlayer } from './PlayerContext.js'
import Cover from '../components/Cover.js'

export default function MiniBar() {
  const { state, toggle, next, prev, close } = usePlayer()
  const [expanded, setExpanded] = useState(false)
  if (!state.album) return null

  const current = state.tracks[state.index]

  if (expanded) {
    return (
      <div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl rounded-t-2xl border border-neutral-700 bg-neutral-900 p-4">
        <div className="flex items-center justify-between">
          <div className="min-w-0">
            <p className="truncate font-medium">{state.album.title}</p>
            <p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
          </div>
          <div className="flex gap-2">
            <button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
              
            </button>
            <button type="button" onClick={close} aria-label="close player" className="text-neutral-400">
              
            </button>
          </div>
        </div>
        <ol className="mt-3 max-h-64 space-y-1 overflow-y-auto text-sm">
          {state.tracks.map((t, i) => (
            <li
              key={t.id}
              className={`flex justify-between rounded-lg px-2 py-1 ${i === state.index ? 'bg-neutral-800 text-emerald-400' : 'text-neutral-300'}`}
            >
              <span className="truncate">
                {t.track ?? i + 1}. {t.title}
              </span>
              {t.duration != null && <span className="ml-2 shrink-0 text-neutral-500">{Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}</span>}
            </li>
          ))}
        </ol>
      </div>
    )
  }

  return (
    <div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl px-4">
      <div className="flex items-center gap-3 rounded-2xl border border-neutral-700 bg-neutral-900/95 p-2 shadow-lg backdrop-blur">
        <button type="button" onClick={() => setExpanded(true)} aria-label="expand player" className="shrink-0">
          <Cover src={null} alt="" className="size-10" />
        </button>
        <div className="min-w-0 flex-1">
          <p className="truncate text-sm font-medium">{current?.title}</p>
          <p className="truncate text-xs text-neutral-400">{state.album.title}</p>
        </div>
        <button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
          
        </button>
        <button
          type="button"
          onClick={toggle}
          aria-label={state.playing ? 'pause' : 'play'}
          className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
        >
          {state.playing ? '⏸' : '▶'}
        </button>
        <button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
          
        </button>
        <button type="button" onClick={close} aria-label="close player" className="px-1 text-neutral-500">
          
        </button>
      </div>
    </div>
  )
}

(A seek bar is deferred: the audio element's seek needs ref plumbing between MiniBar and the provider's audio element. The spec's mini-bar lists seek — note in the task report if omitted, or implement via usePlayer() gaining seek(t: number) + a currentTimeRef — coordinator decision: seek lands with the expanded player in a follow-up fix if the simple version feels incomplete. Prefer shipping the above and adding seek during review if trivial.)

  • Step 4: Modify web/src/App.tsx — wrap with PlayerProvider and render MiniBar:
import { PlayerProvider } from './player/PlayerContext.js'
import MiniBar from './player/MiniBar.js'
  • Outermost: <AuthProvider>... stays; inside it wrap the whole <BrowserRouter> content with <PlayerProvider>; render <MiniBar /> inside the protected layout route element, after <Shell />:
          <Route
            element={
              <Gate>
                <PlayerProvider>
                  <Shell />
                  <MiniBar />
                </PlayerProvider>
              </Gate>
            }
          >

(The audio element lives in PlayerProvider; rendering MiniBar inside the same provider gives it context access. Keep <audio> in the provider — it renders nothing visible.)

  • Step 5: Run tests

Run: npx vitest run web/test/minibar.test.tsx && npm test && npm run typecheck Expected: ALL PASS (175 + 3 new = 178). Existing router tests render App — PlayerProvider mounts an <audio> element in jsdom (harmless); MiniBar hidden without a queue.

  • Step 6: Commit
git add -A && git commit -m "feat: mini player bar with expandable track list"

Task 11: Item page — Play, last played, loans

Files:

  • Modify: web/src/pages/ItemPage.tsx

  • Test: web/test/item.test.tsx (append)

  • Step 1: Append failing tests to web/test/item.test.tsx

The file's existing mocks: api module with getItem/setRip/setMatch/deleteItem/searchAlbums. Add getAlbumTracks/markPlayed/lendItem/getLoans/returnLoan to the override list; getItem fixture gains matchedAlbum:

import type { Item, MatchedAlbum } from '../src/types.js'

const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
const rippedItem: Item = { ...item, ripStatus: 'ripped', matchedAlbum: matched }

(Adjust the base item fixture: it must now carry matchedAlbum: null so existing tests keep passing; the default getItem mock resolves the base item.)

  it('shows Play for a ripped item with a matched album and loads the player', async () => {
    vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
    vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
    vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
    renderItem()
    const play = await screen.findByRole('button', { name: /play album/i })
    await userEvent.click(play)
    await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
    await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
  })

  it('hides Play when unmatched or not ripped', async () => {
    vi.mocked(api.getItem).mockResolvedValue(item as never) // not ripped, matchedAlbum null
    renderItem()
    await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
    expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
  })

  it('shows last played under the rip banner', async () => {
    vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
    renderItem()
    expect(await screen.findByText(/last played/i)).toBeTruthy()
  })

  it('lend and return flow', async () => {
    vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
    vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never)
    renderItem()
    await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
    await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
    await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob'))
    await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())

    vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
    vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem } as never)
    await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
    await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
  })

(The lend flow's second phase needs the item's loan state — the page re-fetches the item after lend (getItem again) and shows 'Out to Bob…' + 'Mark returned' when loan present. See implementation: the detail response gains loan: Loan | null — ADD to the backend detail route in this task: loan: activeLoanFor(db, row.id) where activeLoanFor queries loans WHERE item_id = ? AND returned_at IS NULL. That is a small backend addition folded here: modify server/src/routes/collectionRoutes.ts detail route to include loan, and extend the backend test in server/test/loans.test.ts asserting detail.json().loan after lending.)

  • Step 2: Run test to verify it fails

Run: npx vitest run web/test/item.test.tsx Expected: FAIL — no Play/loan UI.

  • Step 3: Implement

Backend first — server/src/routes/collectionRoutes.ts detail route returns loan too:

    const loan = db
      .prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
      .get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
    return {
      ...rowToItem(db, row),
      matchedAlbum: findMatchedAlbum(db, request.user.id, row.id),
      loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
    }

(+ backend test assertion in loans.test.ts; type ItemDetail in web/src/types.ts gains loan: { id: number; borrower: string; lentAt: string } | null.)

Frontend — in web/src/pages/ItemPage.tsx:

  • Type the item state as ItemDetail | null
  • After the rip banner, before the toggle buttons:
      {item.ripStatus === 'ripped' && item.matchedAlbum && (
        <button
          type="button"
          onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
          className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
        >
           Play album
        </button>
      )}
      {item.matchedAlbum?.lastPlayedAt && (
        <p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
      )}
  • load from usePlayer(); timeAgo(iso) — small local helper (minutes/hours/days/months/years, 'just now' < 60s); define in the file.
  • Loan section (after re-match section):
      <section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
        {item.loan ? (
          <div className="flex items-center justify-between">
            <p className="text-sm text-neutral-300">
              Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
            </p>
            <button
              type="button"
              onClick={() => void api.returnLoan(item.loan!.id).then(() => refetch())}
              className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
            >
              Mark returned
            </button>
          </div>
        ) : (
          <form
            onSubmit={(e) => {
              e.preventDefault()
              if (!item) return
              const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
              void api.lendItem(item.id, borrower).then(() => refetch())
            }}
            className="flex gap-2"
          >
            <input
              name="borrower"
              aria-label="Borrower"
              placeholder="Lend to…"
              required
              className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
            />
            <button type="submit" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300">
              Lend
            </button>
          </form>
        )}
      </section>
  • refetch() = re-run api.getItem(Number(id)).then(setItem) — extract the load into a refetch callback used by the mount effect too.

  • Step 4: Run tests

Run: npx vitest run web/test/item.test.tsx server/test/loans.test.ts && npm test && npm run typecheck Expected: ALL PASS (178 + 4 new = 182).

  • Step 5: Commit
git add -A && git commit -m "feat: play button, last played, loan lend/return on item page"

Files:

  • Modify: web/src/pages/LibraryPage.tsx, web/src/App.tsx

  • Create: web/src/pages/QueuePage.tsx

  • Test: web/test/library.test.tsx (append), web/test/queue.test.tsx (new)

  • Step 1: Append failing tests to web/test/library.test.tsx

  it('header links to stats and queue', async () => {
    renderLibrary()
    await waitFor(() => expect(screen.getByRole('link', { name: /stats/i })).toHaveProperty('href'))
    expect(screen.getByRole('link', { name: /queue/i })).toBeTruthy()
  })

  it('on-loan chip filters by loan state', async () => {
    renderLibrary()
    await waitFor(() => expect(screen.getByRole('button', { name: /^on loan$/i })).toBeTruthy())
    await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
    await waitFor(() =>
      expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ onLoan: 'true' }))
    )
    await userEvent.click(screen.getByRole('button', { name: /^on loan$/i }))
    await waitFor(() =>
      expect(api.listCollection).toHaveBeenLastCalledWith(expect.not.objectContaining({ onLoan: expect.anything() }))
    )
  })

(The plan's toHaveProperty('href') pattern from earlier tasks applies — use the file's existing link-assertion style.)

  • Step 2: Write the failing test web/test/queue.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import QueuePage from '../src/pages/QueuePage.js'
import type { Item } from '../src/types.js'

vi.mock('../src/api.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/api.js')>()
  return { ...actual, api: { ...actual.api, listCollection: vi.fn(), setRip: vi.fn() } }
})
import { api } from '../src/api.js'

function qItem(id: number, title: string, added: string): Item {
  return {
    id, discogsReleaseId: 1, title, artist: 'Artist', year: 1999, formats: ['CD'], genres: [], labels: [],
    tracklist: [], catno: null, country: null, artworkUrl: null, barcodes: [], dateAdded: added,
    ripOverride: null, ripStatus: 'not_ripped',
  }
}

beforeEach(() => {
  vi.mocked(api.listCollection).mockReset()
  vi.mocked(api.setRip).mockReset()
})

describe('QueuePage', () => {
  it('lists not-ripped items oldest first and marks ripped', async () => {
    vi.mocked(api.listCollection).mockResolvedValue({
      items: [qItem(1, 'Newest', '2026-08-20'), qItem(2, 'Oldest', '2026-08-01')],
      counts: { total: 2, ripped: 0, notRipped: 2 },
    } as never)
    vi.mocked(api.setRip).mockResolvedValue({} as never)
    render(
      <MemoryRouter>
        <QueuePage />
      </MemoryRouter>
    )
    expect(await screen.findByText('Oldest')).toBeTruthy() // oldest first
    const rows = screen.getAllByRole('button', { name: /mark ripped/i })
    await userEvent.click(rows[0]!)
    await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(2, true))
    await waitFor(() => expect(screen.queryByText('Oldest')).toBeNull())
  })

  it('empty state points back to scanning', async () => {
    vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
    render(
      <MemoryRouter>
        <QueuePage />
      </MemoryRouter>
    )
    expect(await screen.findByText(/nothing waiting/i)).toBeTruthy()
  })
})
  • Step 3: Run tests to verify they fail

Run: npx vitest run web/test/library.test.tsx web/test/queue.test.tsx Expected: FAIL — no header links, no QueuePage.

  • Step 4: Implement

web/src/pages/QueuePage.tsx — full file:

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import type { Item } from '../types.js'
import Cover from '../components/Cover.js'

export default function QueuePage() {
  const [items, setItems] = useState<Item[] | null>(null)
  const [error, setError] = useState(false)

  useEffect(() => {
    void api
      .listCollection({ ripped: 'not_ripped' })
      .then((res) => setItems([...res.items].reverse()))
      .catch(() => setError(true))
  }, [])

  function markRipped(id: number) {
    void api
      .setRip(id, true)
      .then(() => setItems((list) => (list ?? []).filter((i) => i.id !== id)))
      .catch(() => setError(true))
  }

  return (
    <div className="space-y-3">
      <p className="text-sm text-neutral-400">Rip queue  oldest additions first.</p>
      {error && <p className="text-sm text-red-400">Could not load the queue.</p>}
      {items && items.length === 0 && (
        <div className="py-12 text-center">
          <p className="text-neutral-400">Nothing waiting to be ripped.</p>
          <Link to="/scan" className="mt-2 inline-block text-sm text-emerald-400">
            Scan something 
          </Link>
        </div>
      )}
      {items &&
        items.map((item) => (
          <div key={item.id} className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
            <Cover src={item.artworkUrl} alt="" className="size-12 shrink-0" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-medium">{item.title}</p>
              <p className="truncate text-xs text-neutral-400">
                {item.artist} · added {new Date(item.dateAdded + 'Z').toLocaleDateString()}
              </p>
            </div>
            <button
              type="button"
              onClick={() => markRipped(item.id)}
              className="shrink-0 rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950"
            >
              Mark ripped
            </button>
            <Link to={`/item/${item.id}`} className="shrink-0 text-xs text-neutral-400">
              details
            </Link>
          </div>
        ))}
    </div>
  )
}

web/src/pages/LibraryPage.tsx — two edits: header row above the search input:

      <div className="flex gap-3 text-sm">
        <Link to="/stats" className="text-emerald-400">
          Stats
        </Link>
        <Link to="/queue" className="text-emerald-400">
          Queue
        </Link>
      </div>

and a loan chip after the rip chips (RIP row): a third chip group rendered like the others — state onLoan: boolean, chip label 'On loan', toggling sets/clears, params include ...(onLoan ? { onLoan: 'true' } : {}):

        <button
          type="button"
          onClick={() => setOnLoan((v) => !v)}
          className={`rounded-full px-3 py-1 text-xs font-medium ${
            onLoan ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
          }`}
        >
          On loan
        </button>

(effect deps gain onLoan; params builder gains the onLoan spread.)

web/src/App.tsx — import QueuePage + route inside the protected block: <Route path="/queue" element={<QueuePage />} />.

  • Step 5: Run tests

Run: npx vitest run web/test/library.test.tsx web/test/queue.test.tsx && npm test && npm run typecheck Expected: ALL PASS (182 + 4 new = 186).

  • Step 6: Commit
git add -A && git commit -m "feat: rip queue page, library stats/queue links and on-loan chip"

Task 13: Stats page

Files:

  • Create: web/src/pages/StatsPage.tsx

  • Modify: web/src/App.tsx (route)

  • Test: web/test/stats.test.tsx (new)

  • Step 1: Write the failing test web/test/stats.test.tsx

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import StatsPage from '../src/pages/StatsPage.js'
import type { Stats } from '../src/types.js'

vi.mock('../src/api.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/api.js')>()
  return { ...actual, api: { ...actual.api, getStats: vi.fn() } }
})
import { api } from '../src/api.js'

const stats: Stats = {
  totals: { items: 4, ripped: 1, notRipped: 3, onLoan: 1 },
  ripRatio: 0.25,
  formats: [
    { name: 'CD', count: 3 },
    { name: 'Vinyl', count: 1 },
  ],
  decades: [{ name: '1990s', count: 4 }],
  topGenres: [
    { name: 'Electronic', count: 3 },
    { name: 'Downtempo', count: 2 },
  ],
  topArtists: [{ name: 'Massive Attack', count: 2 }],
  addedByMonth: [
    { month: '2025-10', count: 0 },
    { month: '2025-11', count: 0 },
    { month: '2025-12', count: 0 },
    { month: '2026-01', count: 0 },
    { month: '2026-02', count: 0 },
    { month: '2026-03', count: 0 },
    { month: '2026-04', count: 0 },
    { month: '2026-05', count: 0 },
    { month: '2026-06', count: 0 },
    { month: '2026-07', count: 0 },
    { month: '2026-08', count: 4 },
    { month: '2026-09', count: 0 },
  ],
}

beforeEach(() => {
  vi.mocked(api.getStats).mockReset()
  vi.mocked(api.getStats).mockResolvedValue(stats as never)
})

describe('StatsPage', () => {
  it('renders totals and rip ratio', async () => {
    render(
      <MemoryRouter>
        <StatsPage />
      </MemoryRouter>
    )
    expect(await screen.findByText('4')).toBeTruthy()
    expect(screen.getByText(/25% ripped/i)).toBeTruthy()
    expect(screen.getByText(/1 on loan/i)).toBeTruthy()
  })

  it('renders format, genre, artist and month bars', async () => {
    render(
      <MemoryRouter>
        <StatsPage />
      </MemoryRouter>
    )
    expect(await screen.findByText('CD')).toBeTruthy()
    expect(screen.getByText('Electronic')).toBeTruthy()
    expect(screen.getByText('Massive Attack')).toBeTruthy()
    expect(screen.getByText('1990s')).toBeTruthy()
    // month bars: 12 bars rendered
    expect(document.querySelectorAll('[data-bar]').length).toBeGreaterThanOrEqual(12)
  })

  it('error state on failure', async () => {
    vi.mocked(api.getStats).mockRejectedValue(new TypeError('fetch failed'))
    render(
      <MemoryRouter>
        <StatsPage />
      </MemoryRouter>
    )
    expect(await screen.findByText(/could not load stats/i)).toBeTruthy()
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run web/test/stats.test.tsx Expected: FAIL — module not found.

  • Step 3: Create web/src/pages/StatsPage.tsx — full file:
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import type { Stats } from '../types.js'

function Bar({ name, count, max }: { name: string; count: number; max: number }) {
  return (
    <div className="flex items-center gap-2 text-sm">
      <span className="w-28 shrink-0 truncate text-neutral-300">{name}</span>
      <div className="h-2.5 flex-1 overflow-hidden rounded-full bg-neutral-800">
        <div className="h-full rounded-full bg-emerald-500" style={{ width: `${max === 0 ? 0 : (count / max) * 100}%` }} />
      </div>
      <span className="w-8 shrink-0 text-right text-neutral-500">{count}</span>
    </div>
  )
}

function Card({ value, label }: { value: string | number; label: string }) {
  return (
    <div className="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-center">
      <p className="text-2xl font-semibold">{value}</p>
      <p className="text-xs text-neutral-400">{label}</p>
    </div>
  )
}

export default function StatsPage() {
  const [stats, setStats] = useState<Stats | null>(null)
  const [error, setError] = useState(false)

  useEffect(() => {
    void api
      .getStats()
      .then(setStats)
      .catch(() => setError(true))
  }, [])

  if (error) return <p className="py-8 text-center text-sm text-red-400">Could not load stats.</p>
  if (!stats) return <p className="py-8 text-center text-sm text-neutral-400">Loading</p>

  const maxOf = (rows: { count: number }[]) => Math.max(1, ...rows.map((r) => r.count))
  const maxMonth = Math.max(1, ...stats.addedByMonth.map((m) => m.count))
  const ratioPct = Math.round(stats.ripRatio * 100)

  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
        <Card value={stats.totals.items} label="in collection" />
        <Card value={stats.totals.ripped} label="ripped" />
        <Card value={stats.totals.notRipped} label="not ripped" />
        <Card value={stats.totals.onLoan} label="on loan" />
      </div>

      <div className="flex items-center gap-4 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <div
          className="size-20 shrink-0 rounded-full"
          style={{
            background: `conic-gradient(#34d399 ${ratioPct}%, #262626 ${ratioPct}% 100%)`,
          }}
          role="img"
          aria-label={`rip ratio ${ratioPct}%`}
        />
        <p className="text-sm text-neutral-300">{ratioPct}% ripped</p>
      </div>

      <section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Formats</h2>
        {stats.formats.length === 0 && <p className="text-sm text-neutral-500">No data yet.</p>}
        {stats.formats.map((f) => (
          <Bar key={f.name} name={f.name} count={f.count} max={maxOf(stats.formats)} />
        ))}
      </section>

      <section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Genres</h2>
        {stats.topGenres.map((g) => (
          <Bar key={g.name} name={g.name} count={g.count} max={maxOf(stats.topGenres)} />
        ))}
      </section>

      <section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Top artists</h2>
        {stats.topArtists.map((a) => (
          <Bar key={a.name} name={a.name} count={a.count} max={maxOf(stats.topArtists)} />
        ))}
      </section>

      <section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Decades</h2>
        {stats.decades.map((d) => (
          <Bar key={d.name} name={d.name} count={d.count} max={maxOf(stats.decades)} />
        ))}
      </section>

      <section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Added per month</h2>
        <div className="flex h-24 items-end gap-1">
          {stats.addedByMonth.map((m) => (
            <div key={m.month} className="flex flex-1 flex-col items-center gap-1" title={`${m.month}: ${m.count}`}>
              <div
                data-bar
                className="w-full rounded-t bg-emerald-500"
                style={{ height: `${(m.count / maxMonth) * 100}%`, minHeight: m.count > 0 ? '4px' : '1px', background: m.count > 0 ? '#34d399' : '#262626' }}
              />
              <span className="text-[10px] text-neutral-500">{m.month.slice(5)}</span>
            </div>
          ))}
        </div>
      </section>
    </div>
  )
}
  • Step 4: Register the route in web/src/App.tsx — import StatsPage + <Route path="/stats" element={<StatsPage />} /> in the protected block.

  • Step 5: Run tests

Run: npx vitest run web/test/stats.test.tsx && npm test && npm run typecheck Expected: ALL PASS (186 + 3 new = 189).

  • Step 6: Commit
git add -A && git commit -m "feat: stats page with css bar charts and rip-ratio donut"

Task 14: Settings — Data section (export + backups)

Files:

  • Modify: web/src/pages/SettingsPage.tsx

  • Test: web/test/settings.test.tsx (append)

  • Step 1: Append failing tests to web/test/settings.test.tsx

(Extend the api mock override list with triggerBackup/getBackups. The file's fetch stub pattern for AuthProvider stays.)

  it('data section: export link, backup now, backups list', async () => {
    vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never)
    vi.mocked(api.getBackups).mockResolvedValue({
      backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }],
    } as never)
    renderSettings()
    expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href')
    await userEvent.click(screen.getByRole('button', { name: /back up now/i }))
    await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled())
    await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy())
  })

  it('backup failure surfaces in the flash banner', async () => {
    vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed'))
    renderSettings()
    await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
    await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
  })

(Use the file's existing link-assertion style if toHaveProperty clashes.)

  • Step 2: Run test to verify it fails

Run: npx vitest run web/test/settings.test.tsx Expected: FAIL — no Data section.

  • Step 3: Implement — add to web/src/pages/SettingsPage.tsx (after the Library sync Section, before Users):
      <Section title="Data">
        <div className="flex flex-wrap gap-2">
          <a
            href={api.exportUrl()}
            download
            className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
          >
            Export JSON
          </a>
          <button
            type="button"
            onClick={() =>
              void api
                .triggerBackup()
                .then(() => api.getBackups())
                .then((res) => setBackups(res.backups))
                .then(() => flash('ok', 'Backup created'))
                .catch(() => flash('error', 'Backup failed'))
            }
            className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
          >
            Back up now
          </button>
        </div>
        {backups && backups.length > 0 && (
          <ul className="space-y-1 text-xs text-neutral-400">
            {backups.map((b) => (
              <li key={b.file} className="flex justify-between gap-2">
                <span className="truncate">{b.file}</span>
                <span className="shrink-0">
                  {(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
                </span>
              </li>
            ))}
          </ul>
        )}
      </Section>

State: const [backups, setBackups] = useState<BackupFile[] | null>(null) (+ type import) and load on mount for admins:

  useEffect(() => {
    if (user?.isAdmin) {
      void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
    }
  }, [user])

(The existing admin listUsers effect can be extended instead of a second effect — implementer's choice, one effect is fine.)

  • Step 4: Run tests

Run: npx vitest run web/test/settings.test.tsx && npm test && npm run typecheck Expected: ALL PASS (189 + 2 new = 191).

  • Step 5: Commit
git add -A && git commit -m "feat: settings data section with export and backups"

Task 15: Full verification + deploy note

Files:

  • Modify: none (verification task)

  • Step 1: Full suite + typecheck + build

Run: npm test && npm run typecheck && npm run build Expected: 191 tests pass (82 backend baseline + 109 across wave-1 backend/frontend), typecheck clean, both dists built.

  • Step 2: Verify the migration on the deployed instance's data (dry run)

The deployed instance at /home/samu/record-shop/data/record-shop.db has real data (if the user has been using it) and runs the pre-v2 schema. Before restarting it, copy the DB and open the copy:

cp data/record-shop.db /tmp/opencode/rs-migrate-test.db
node -e "
import Database from 'better-sqlite3';
import { migrateUpgrades } from './server/dist/db.js';
const db = new Database('/tmp/opencode/rs-migrate-test.db');
migrateUpgrades(db);
console.log('version:', db.prepare(\"SELECT value FROM app_meta WHERE key='schema_version'\").get());
console.log('cols:', db.prepare('PRAGMA table_info(digital_albums)').all().map(c => c.name).join(','));
"

Expected: version: {value: '2'} and last_played_at present, existing rows intact. Delete the test copy afterwards.

  • Step 3: Restart the deployed instance
pkill -f "node /home/samu/record-shop/server/dist/index.js"
sleep 1
npm run build
setsid nohup node /home/samu/record-shop/server/dist/index.js > /home/samu/record-shop/data/server.log 2>&1 < /dev/null &
sleep 2
curl -s http://localhost:3000/api/health
curl -s http://localhost:3000/ | grep -o '<title>[^<]*</title>'

Expected: {"ok":true} + SPA title; migration runs on boot against the real data.

  • Step 4: Manual device checklist additions (record, do not automate)

  • Play an album on the phone (audio codec support, seek, auto-advance)

  • Mini-bar persists across tab navigation

  • Lend/return from the item page

  • Export download on the phone (files app)

  • Step 5: Commit (if anything changed) or close out

git status --short   # should be clean; the deploy artifacts are gitignored

API additions summary (reference)

Method Path Notes
GET /api/stream/:songId Range passthrough, 502 stream_unavailable
GET /api/album/:subsonicId/tracks {album:{id,title,artist}, tracks:[{id,title,duration,track}]}
POST /api/album/:subsonicId/played stamps last_played_at (404 unknown)
GET /api/stats totals + ripRatio + formats/decades/genres/artists + addedByMonth
GET /api/export per-user JSON backup artifact (no secrets)
POST /api/backup admin; prunes to 7
GET /api/backups admin; file list
POST /api/collection/:id/loan 400 empty borrower, 409 already_on_loan
GET /api/loans {active, history}
POST /api/loans/:id/return 404 when missing/returned
GET /api/collection/:id + matchedAlbum, + loan
GET /api/collection?onLoan= loan-state filter

Detail response additions: matchedAlbum: {id, subsonicId, lastPlayedAt} | null, loan: {id, borrower, lentAt} | null.