1
0

Compare commits

..

32 Commits

Author SHA1 Message Date
b55a1f8cc1 fix: atomic setup transaction, prune devDependencies from runtime image 2026-08-29 18:46:07 +02:00
56ffda9e88 feat: release payload disk cache, batched rip status for list, artwork 404, stricter input handling 2026-08-29 18:44:08 +02:00
39c956e67d fix: sync removes albums no longer in subsonic, classify non-json responses as api errors 2026-08-29 18:39:46 +02:00
ba705d546a feat: docker deployment with compose quickstart 2026-08-29 18:07:43 +02:00
061f7349b5 fix: robust web/dist path resolution and graceful shutdown 2026-08-29 17:59:22 +02:00
a3bf420282 feat: serve built SPA with fallback and artwork cache 2026-08-29 17:46:52 +02:00
875bc1087c feat: background subsonic library sync with per-user state 2026-08-29 17:34:05 +02:00
f1d6320dfe fix: validate match album before discogs fetch, guard barcode type 2026-08-29 17:30:02 +02:00
566a9bfc0d feat: collection CRUD with artwork caching, rip override and match links 2026-08-29 17:19:17 +02:00
ded4e17849 feat: discogs lookup routes (barcode/search/release preview)
Known-red tests pending later tasks:
- 'reports duplicate when release already in collection' needs
  POST /api/collection (Task 15)
- 'reports ripped...' and 'reports ambiguous...' need the test-only
  POST /api/library/albums/test-seed route (Task 16)
2026-08-29 17:07:04 +02:00
e8a83eeefb feat: rip status resolution with override and match links 2026-08-29 16:57:34 +02:00
c49321ccf1 feat: match ae/oe ligatures in normalization 2026-08-29 16:54:59 +02:00
217c676658 feat: title/artist normalization and matching 2026-08-29 16:48:15 +02:00
c1dd232a2f fix: artwork cache never throws on body/disk failures 2026-08-29 16:43:45 +02:00
0faa74ae27 feat: best-effort artwork disk cache 2026-08-29 16:38:28 +02:00
9b52039fde fix: skip null album entries in subsonic pagination 2026-08-29 16:35:01 +02:00
dc63db54ba feat: subsonic library pagination 2026-08-29 16:31:08 +02:00
72f3fc8436 fix: harden discogs mappers against malformed payloads, name error classes, assert auth header 2026-08-29 16:24:40 +02:00
3715bb7447 feat: discogs client with barcode/text search and release fetch 2026-08-29 16:13:43 +02:00
b79a72a81b feat: serializing queue with min-interval pacing 2026-08-29 16:05:29 +02:00
a54f56834d fix: classify invalid subsonic URL as unreachable, type settings handlers, guard token mask and body types 2026-08-29 16:01:07 +02:00
e94ffb535b feat: per-user settings routes with subsonic validation 2026-08-29 15:51:58 +02:00
6dfaaf465d fix: map duplicate-username race to 409 and test session/cascade behavior 2026-08-29 15:46:34 +02:00
8bdbe292d5 feat: admin user management routes 2026-08-29 15:35:24 +02:00
1c52281feb refactor: type auth preHandlers and mirror clearCookie options 2026-08-29 15:31:36 +02:00
ad8b7cb22a feat: setup/login/logout/me auth routes 2026-08-29 15:20:42 +02:00
f48093319b feat: argon2 passwords and sqlite-backed sessions 2026-08-29 15:11:15 +02:00
dff7b4826d feat: real entrypoint using loadConfig 2026-08-29 15:05:46 +02:00
5545aa31fb feat: config loading and sqlite schema 2026-08-29 14:55:42 +02:00
01ad6fbb40 chore: scaffold node/ts/vitest project with health endpoint 2026-08-29 14:43:44 +02:00
52b2cbd6c5 docs: backend implementation plan (18 tasks, TDD) 2026-08-29 14:32:57 +02:00
1dfe47f5dd Add design spec for record-shop webapp 2026-08-29 13:59:35 +02:00
50 changed files with 10485 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
server/dist
web/dist
data
.git
.gitignore
*.log
.DS_Store

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
server/dist/
web/dist/
data/
*.log
.DS_Store
coverage/
.env*

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---- build stage: deps + compile + test ----
FROM node:22-slim AS build
WORKDIR /app
# Install build tools as a fallback for native modules without prebuilds
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json vitest.config.ts ./
COPY server ./server
RUN npm run build:server && npm test
RUN npm prune --omit=dev
# ---- runtime stage ----
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/dist ./server/dist
ENV PORT=3000
ENV DATA_DIR=/data
VOLUME /data
EXPOSE 3000
CMD ["node", "server/dist/index.js"]

View File

@@ -1,3 +1,29 @@
# record-shop
Easily manage a physical collection of music.
Easily manage a physical collection of music.
Scan barcodes with your phone to catalogue vinyl, CDs and cassettes, pull
metadata from Discogs, and see what you have (and haven't) ripped into your
Subsonic-compatible music server (Navidrome, Gonic, Airsonic, LMS, …).
## Quickstart
```bash
docker compose up -d
```
Open <http://localhost:3000>, create the admin account, then add your Discogs
token and (optionally) your Subsonic server details in Settings.
- Data lives in `./data` (SQLite database + cached cover art).
- Configure via env if you prefer: `PORT`, `DATA_DIR`.
## Development
```bash
npm install
npm test # vitest
npm run dev:server
```
Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`

0
SPEC.md Normal file
View File

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
record-shop:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/data
restart: unless-stopped

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
# record-shop — Design Spec
**Date:** 2026-08-29
**Status:** Approved design, pending implementation
## Purpose
A self-hosted webapp for cataloguing a physical music collection (vinyl, CDs, cassettes) and tracking what has been ripped into the owner's digital library. The flagship interaction is scanning a physical item's barcode with a phone camera to add it to the collection in seconds, with metadata pulled from Discogs and an automatic check against the digital library.
**Success criteria:**
- Deployable with `docker compose up -d` and zero required env vars
- Adding a barcoded item takes under 15 seconds on a phone
- Rip status is visibly accurate for items whose album exists in a Subsonic-compatible library
- Usable one-handed on a phone; installable as a PWA
## Architecture
Single Node.js container running a Fastify server that serves:
- The REST API under `/api/*`
- The built React SPA (Vite + Tailwind CSS, mobile-first)
**Storage:** SQLite via `better-sqlite3` (WAL mode), database file at `/data/record-shop.db`. Artwork fetched from Discogs is proxied through the server and cached on disk at `/data/artwork-cache/`, keyed by Discogs image URL hash.
**Barcode decoding** happens client-side: the native `BarcodeDetector` API where available (most Android browsers), with a ZXing-wasm fallback for iOS Safari. Supported symbologies: EAN-13, UPC-A, EAN-8.
**PWA:** manifest + minimal service worker so the app installs to a phone home screen.
### Dependencies (per-container, no external services)
| Concern | Choice |
| --- | --- |
| HTTP server | Fastify |
| Database | SQLite (better-sqlite3, WAL) |
| Sessions | httpOnly cookie, server-side session store in SQLite; secret generated at first boot and persisted in `/data` |
| Password hashing | argon2 |
| Frontend | React + Vite + Tailwind CSS |
| Barcode decode | `BarcodeDetector` API, ZXing-wasm fallback |
| Discogs access | Personal access token (free), per user |
| Digital library | Subsonic API (Navidrome, Gonic, Airsonic, LMS, …) |
## Users and auth
- First run shows a setup screen that creates the admin account. Admin creates additional users.
- Auth: username + password (argon2), httpOnly session cookie.
- Multi-user with **separate private collections**: each user scans into and sees only their own collection.
- Per-user settings, stored server-side: Discogs personal access token; Subsonic URL, username, and password.
## Data model (SQLite)
- `users` — id, username, password_hash, is_admin, created_at
- `settings` — user_id (PK), discogs_token, subsonic_url, subsonic_username, subsonic_password
- `collection_items` — id, user_id, discogs_release_id, title, artist, year, formats (JSON), local_artwork_path, barcodes (JSON, may be empty), date_added. Unique on (user_id, discogs_release_id).
- `rip_status` on each collection item:
- `null` — automatic: derived from library match state
- `true` / `false` — manual override, wins over everything
- `digital_albums` — id, user_id, subsonic_id, title, artist, synced_at. Per-user cache of the Subsonic library, refreshed by sync.
- `match_links` — user_id, collection_item_id, digital_album_id. Manual mapping between a physical item and a digital album, set when fuzzy matching is ambiguous or wrong.
Discogs metadata (title, artist, year, formats, tracklist, labels, genres, images) is denormalized into `collection_items` and the disk cache at add time, so the collection remains readable even if Discogs is unreachable later.
## Core flows
### Scan flow (primary)
1. Tap the scan tab → camera view opens with a viewfinder overlay guiding the barcode into frame.
2. Barcode decoded client-side → `GET /api/lookup/barcode/:code` → server queries Discogs `database/search?barcode=...&type=release`.
3. **0 results** → "Nothing found" screen with a one-tap text-search fallback.
4. **1+ results** → candidate cards (cover, title, artist, year, format, country). User taps the correct edition.
5. Server fetches the full release (`GET /releases/:id`), then:
- Runs the rip check against the user's `digital_albums` cache.
- Checks for a duplicate: same (user, discogs_release_id) already in the collection → warning shown.
6. Confirm screen: cover, metadata, **"In your digital collection ✓ / Not ripped yet ✗"** banner, "Add to collection" button.
7. Item added → user lands on its detail page.
### Text-search flow (fallback for unscannable items)
Same pipeline from step 4 onward, via `GET /api/lookup/search?q=...`, filterable by format. Entry point on the Add tab. Used for most vinyl and cassettes.
### Library sync flow
- "Sync library now" (Settings and Library screens) paginates Subsonic `getAlbumList2`, upserting into `digital_albums`.
- Runs in the background with a progress indicator; "last synced X ago" stamp shown.
- Saving a Subsonic config triggers a first sync immediately.
### Rip status resolution (computed per item, cached)
1. Manual override (`rip_status` non-null) wins, always.
2. Else a manual `match_link` decides.
3. Else fuzzy auto-match against `digital_albums`: confident match → ripped; no match → not ripped.
4. Library re-syncs never clobber manual overrides or confirmed match links.
**Fuzzy matching rule:** normalize strings (lowercase, strip punctuation, strip leading articles "the"/"a"/"an", collapse whitespace). A **confident match** requires normalized artist and normalized title to be equal. Anything else (near-misses, multiple candidates, no match) is **ambiguous**: the app asks the user to confirm once from a short candidate list, and the confirmed link is stored in `match_links`.
### Discogs rate limiting
Authenticated Discogs allows 60 requests/minute. All Discogs calls go through a server-side serializing queue. Full release payloads are cached on disk keyed by release ID, so re-scans and re-visits cost zero Discogs calls.
## UI
Bottom tab bar on mobile: **Library · Scan · Add · Settings**. Desktop renders the same app with a wider responsive grid — no separate layout.
- **Library** — responsive cover grid (34 columns on phone, more on desktop). Filter chips: format (All / Vinyl / CD / Cassette) and rip state (All / Ripped / Not ripped). Search box and artist jump-list. Tap a cover → detail.
- **Item detail** — large cover; Discogs metadata (label, cat#, year, formats, genres, tracklist); rip-status banner; barcode(s); manual rip toggle; "Re-match" button to fix a wrong match; link to Discogs; edit/remove.
- **Add** — camera entry point plus text search.
- **Settings** — Discogs and Subsonic config; library sync; admin user management.
## Error handling
- Camera permission denied → explanation screen with retry.
- Discogs token missing/invalid → persistent banner linking to Settings.
- Discogs 429 → queue absorbs bursts; if the user still hits it, a "slow down, retrying" indicator.
- Subsonic unreachable → banner on Library/Settings with "Retry sync".
- Barcode not found in Discogs (common on older vinyl) → text-search fallback offered.
- Failures are always user-visible with plain phrasing; a scan is never silently lost.
## Testing
- **Backend (Vitest):** fuzzy matcher (unit); Discogs and Subsonic clients with mocked HTTP; API integration tests against in-memory SQLite; rate-limit queue behavior.
- **Frontend (Vitest + Testing Library):** scan-flow state machine (decode → candidates → confirm → added), candidate cards, filters.
- **Manual device checklist:** camera/scanning on iOS Safari and Android Chrome — the one component worth testing on real hardware.
- `docker build` verified before each release.
## Deployment
- Multi-stage Dockerfile: build stage → slim runtime image.
- Exposes port `3000`; one volume at `/data`.
- `docker-compose.yml` in the repo as the blessed path.
- Setup flow after first boot: open browser → create admin → paste Discogs token (and optionally Subsonic config) → start scanning.
## Explicitly out of scope (v1)
- Marketplace/wantlist features of Discogs
- Per-disc rip granularity (compilations where only some discs are ripped)
- Native mobile app
- Other music-server APIs (Jellyfin, Plex) — Subsonic first, by design
- Multi-collection sharing, household accounts

2870
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "record-shop",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"dev:server": "tsx watch server/src/index.ts",
"dev": "npm run dev:server",
"test": "vitest run",
"test:watch": "vitest",
"build:server": "tsc -p server/tsconfig.build.json",
"build": "npm run build:server",
"start": "node server/dist/index.js",
"typecheck": "tsc -p server/tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/static": "^8.0.0",
"argon2": "^0.41.1",
"better-sqlite3": "^13.0.3",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.5.0",
"tsx": "^4.16.0",
"typescript": "^5.5.4",
"vitest": "^3.0.0"
}
}

83
server/src/app.ts Normal file
View File

@@ -0,0 +1,83 @@
import Fastify, { FastifyInstance } from 'fastify'
import Database from 'better-sqlite3'
import cookie from '@fastify/cookie'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import type { Config } from './config.js'
import { SerialQueue } from './queue.js'
import { SyncManager } from './sync.js'
import { ReleaseCache } from './releaseCache.js'
import { registerAuthRoutes } from './routes/authRoutes.js'
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
import { registerLookupRoutes } from './routes/lookupRoutes.js'
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
declare module 'fastify' {
interface FastifyInstance {
db: Database.Database
config: Config
fetchImpl: typeof fetch
discogsQueue: SerialQueue
sync: SyncManager
releaseCache: ReleaseCache
}
interface FastifyRequest {
user?: import('./auth.js').UserRow
}
}
export interface AppOptions {
db: Database.Database
config: Config
fetchImpl?: typeof fetch
/** Directory of the built SPA. Defaults to web/dist when it exists. */
webDist?: string
}
export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
const app = Fastify({ logger: false })
app.decorate('db', opts.db)
app.decorate('config', opts.config)
app.decorate('fetchImpl', opts.fetchImpl ?? fetch)
app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 }))
app.decorate('sync', new SyncManager(opts.db, opts.fetchImpl ?? fetch))
app.decorate('releaseCache', new ReleaseCache(path.join(opts.config.dataDir, 'release-cache')))
await app.register(cookie)
await registerAuthRoutes(app)
await registerSettingsRoutes(app)
await registerLookupRoutes(app)
await registerCollectionRoutes(app)
await registerLibraryRoutes(app)
// artwork cache (always available)
await app.register(fastifyStatic, {
root: opts.config.artworkDir,
prefix: '/artwork/',
decorateReply: false,
})
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
const webDist = opts.webDist ?? path.resolve(moduleDir, '../../web/dist')
const hasWeb = existsSync(webDist)
if (hasWeb) {
await app.register(fastifyStatic, { root: webDist, prefix: '/' })
}
app.setNotFoundHandler((request, reply) => {
const url = request.raw.url ?? ''
if (url.startsWith('/api') || url.startsWith('/artwork')) {
return reply.code(404).send({ error: 'not_found' })
}
if (hasWeb) {
return reply.sendFile('index.html')
}
return reply.code(404).send({ error: 'not_found' })
})
app.get('/api/health', async () => ({ ok: true }))
return app
}

45
server/src/artwork.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
const EXT_BY_TYPE: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
}
/**
* Downloads `url` into `artworkDir` keyed by its sha256 hash. Returns the
* stored file name (e.g. `abc123….jpg`) or null when the url is empty or the
* download fails — artwork caching is best-effort and must never break adds.
*/
export async function cacheArtwork(
artworkDir: string,
url: string,
fetchImpl: typeof fetch = fetch
): Promise<string | null> {
if (!url) return null
const key = createHash('sha256').update(url).digest('hex')
try {
mkdirSync(artworkDir, { recursive: true })
for (const ext of Object.values(EXT_BY_TYPE)) {
if (existsSync(path.join(artworkDir, key + ext))) return key + ext
}
let res: Response
try {
res = await fetchImpl(url)
} catch {
return null
}
if (!res.ok) return null
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? ''
const ext = EXT_BY_TYPE[type] ?? '.jpg'
const buf = Buffer.from(await res.arrayBuffer())
await writeFile(path.join(artworkDir, key + ext), buf)
return key + ext
} catch {
return null
}
}

66
server/src/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
import crypto from 'node:crypto'
import argon2 from 'argon2'
import type { DB } from './db.js'
export interface UserRow {
id: number
username: string
password_hash: string
is_admin: number
created_at: string
}
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password)
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password)
}
export function createUser(db: DB, username: string, passwordHash: string, isAdmin: boolean): UserRow {
const info = db
.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)')
.run(username, passwordHash, isAdmin ? 1 : 0)
return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as UserRow
}
export function getUserByUsername(db: DB, username: string): UserRow | undefined {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined
}
export function getUserById(db: DB, id: number): UserRow | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined
}
export function createSession(db: DB, userId: number): string {
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString()
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
token,
userId,
expiresAt
)
return token
}
export function getUserBySession(db: DB, token: string, now: () => number = Date.now): UserRow | null {
const row = db
.prepare(
`SELECT u.*, s.expires_at FROM sessions s
JOIN users u ON u.id = s.user_id WHERE s.token = ?`
)
.get(token) as (UserRow & { expires_at: string }) | undefined
if (!row) return null
if (new Date(row.expires_at).getTime() < now()) {
deleteSession(db, token)
return null
}
return row
}
export function deleteSession(db: DB, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}

38
server/src/config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
export interface Config {
dataDir: string
artworkDir: string
dbPath: string
port: number
sessionSecret: string
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const dataDir = env.DATA_DIR ?? path.resolve('data')
mkdirSync(dataDir, { recursive: true })
const artworkDir = path.join(dataDir, 'artwork-cache')
mkdirSync(artworkDir, { recursive: true })
return {
dataDir,
artworkDir,
dbPath: path.join(dataDir, 'record-shop.db'),
port: Number(env.PORT ?? 3000),
sessionSecret: getOrCreateSecret(dataDir),
}
}
function getOrCreateSecret(dataDir: string): string {
const secretPath = path.join(dataDir, 'session-secret')
try {
const existing = readFileSync(secretPath, 'utf8').trim()
if (existing) return existing
} catch {
// first boot — create below
}
const secret = crypto.randomBytes(32).toString('hex')
writeFileSync(secretPath, secret, { mode: 0o600 })
return secret
}

80
server/src/db.ts Normal file
View File

@@ -0,0 +1,80 @@
import Database from 'better-sqlite3'
export type DB = Database.Database
export function openDatabase(path: string): DB {
const db = new Database(path)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
migrate(db)
return db
}
export function migrate(db: DB): void {
db.exec(`
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discogs_token TEXT,
subsonic_url TEXT,
subsonic_username TEXT,
subsonic_password TEXT
);
CREATE TABLE IF NOT EXISTS collection_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
discogs_release_id INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
year INTEGER,
formats TEXT NOT NULL DEFAULT '[]',
genres TEXT NOT NULL DEFAULT '[]',
labels TEXT NOT NULL DEFAULT '[]',
tracklist TEXT NOT NULL DEFAULT '[]',
catno TEXT,
country TEXT,
cover_url TEXT,
local_artwork_path TEXT,
barcodes TEXT NOT NULL DEFAULT '[]',
rip_override INTEGER,
date_added TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, discogs_release_id)
);
CREATE TABLE IF NOT EXISTS digital_albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subsonic_id TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
UNIQUE (user_id, subsonic_id)
);
CREATE TABLE IF NOT EXISTS match_links (
user_id INTEGER NOT NULL,
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, item_id)
);
`)
}

157
server/src/discogs.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { SerialQueue } from './queue.js'
export interface DiscogsReleaseSummary {
id: number
artist: string
title: string
year: number | null
formats: string[]
labels: string[]
country: string | null
catno: string | null
thumbUrl: string | null
}
export interface DiscogsReleaseFull extends DiscogsReleaseSummary {
genres: string[]
tracklist: { position: string; title: string }[]
coverUrl: string | null
barcodes: string[]
}
export class DiscogsError extends Error {
constructor(
public status: number,
message: string
) {
super(message)
this.name = new.target.name
}
}
export class DiscogsAuthError extends DiscogsError {
constructor() {
super(401, 'discogs token rejected')
}
}
export class DiscogsRateLimitError extends DiscogsError {
constructor() {
super(429, 'discogs rate limit hit')
}
}
const USER_AGENT = 'record-shop/0.1.0'
function splitTitle(title: string): { artist: string; title: string } {
const idx = title.indexOf(' - ')
if (idx === -1) return { artist: '', title }
return { artist: title.slice(0, idx), title: title.slice(idx + 3) }
}
export function toYear(year: unknown): number | null {
const n = Number(year)
return Number.isInteger(n) && n > 0 ? n : null
}
export function mapSearchResult(r: any): DiscogsReleaseSummary {
const src = r ?? {}
const { artist, title } = splitTitle(String(src.title ?? ''))
return {
id: Number(src.id),
artist,
title,
year: toYear(src.year),
formats: Array.isArray(src.format) ? src.format.filter((f: any) => f != null).map(String) : [],
labels: Array.isArray(src.label) ? src.label.filter((l: any) => l != null).map(String) : [],
country: src.country ?? null,
catno: src.catno ?? null,
thumbUrl: src.thumb ?? src.cover_image ?? null,
}
}
export function mapRelease(r: any): DiscogsReleaseFull {
const src = r ?? {}
return {
id: Number(src.id),
artist: Array.isArray(src.artists) && src.artists[0] ? String(src.artists[0]?.name ?? '') : '',
title: String(src.title ?? ''),
year: toYear(src.year),
formats: Array.isArray(src.formats) ? src.formats.map((f: any) => String(f?.name ?? '')) : [],
labels: Array.isArray(src.labels) ? src.labels.map((l: any) => String(l?.name ?? '')) : [],
country: src.country ?? null,
catno: Array.isArray(src.labels) && src.labels[0] ? (src.labels[0]?.catno ?? null) : null,
thumbUrl: src.thumb ?? (src.images?.[0]?.uri ?? null),
genres: Array.isArray(src.genres) ? src.genres.filter((g: any) => g != null).map(String) : [],
tracklist: Array.isArray(src.tracklist)
? src.tracklist
.filter((t: any) => t?.title)
.map((t: any) => ({ position: String(t?.position ?? ''), title: String(t?.title ?? '') }))
: [],
coverUrl: src.images?.[0]?.uri ?? null,
barcodes: Array.isArray(src.identifiers)
? src.identifiers
.filter((i: any) => i?.type === 'Barcode' && i?.value)
.map((i: any) => String(i.value))
: [],
}
}
export class DiscogsClient {
private token: string
private fetchImpl: typeof fetch
private baseUrl: string
private queue?: SerialQueue
constructor(token: string, fetchImpl: typeof fetch = fetch, baseUrl = 'https://api.discogs.com', queue?: SerialQueue) {
this.token = token
this.fetchImpl = fetchImpl
this.baseUrl = baseUrl
this.queue = queue
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const doFetch = async (): Promise<any> => {
const url = new URL(`${this.baseUrl}${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
let res: Response
try {
res = await this.fetchImpl(url.toString(), {
headers: {
Authorization: `Discogs token=${this.token}`,
'User-Agent': USER_AGENT,
Accept: 'application/json',
},
})
} catch {
throw new DiscogsError(0, 'could not reach api.discogs.com')
}
if (res.status === 401) throw new DiscogsAuthError()
if (res.status === 429) throw new DiscogsRateLimitError()
if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`)
try {
return await res.json()
} catch {
throw new DiscogsError(res.status, 'discogs returned a non-JSON response')
}
}
return this.queue ? this.queue.run(doFetch) : doFetch()
}
async searchByBarcode(barcode: string): Promise<DiscogsReleaseSummary[]> {
const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' })
const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
}
async searchByText(query: string, format?: string): Promise<DiscogsReleaseSummary[]> {
const params: Record<string, string> = { q: query, type: 'release', per_page: '20' }
if (format) params.format = format
const body = await this.get('/database/search', params)
const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
}
async getRelease(id: number): Promise<DiscogsReleaseFull> {
const body = await this.get(`/releases/${id}`)
return mapRelease(body)
}
}

18
server/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { buildApp } from './app.js'
import { openDatabase } from './db.js'
import { loadConfig } from './config.js'
const config = loadConfig()
const db = openDatabase(config.dbPath)
const app = await buildApp({ db, config })
await app.listen({ port: config.port, host: '0.0.0.0' })
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
void (async () => {
await app.close()
db.close()
process.exit(0)
})()
})
}

43
server/src/matcher.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface Matchable {
title: string
artist: string
}
/**
* Normalization for matching: lowercase, strip accents, strip punctuation,
* strip leading articles (the/a/an anywhere as standalone words), collapse
* whitespace. Spec definition of "normalized artist/title equal".
*/
export function normalize(input: string): string {
return input
.toLowerCase()
.replace(/æ/g, 'ae')
.replace(/œ/g, 'oe')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\b(the|a|an)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function isConfidentMatch(a: Matchable, b: Matchable): boolean {
return normalize(a.title) === normalize(b.title) && normalize(a.artist) === normalize(b.artist)
}
/**
* Albums whose normalized title equals the release's normalized title —
* the "ambiguous" candidate list shown when no confident match exists.
* Candidates with matching artist sort first. Capped at 20.
*/
export function candidateAlbums<T extends Matchable>(release: Matchable, albums: T[]): T[] {
const title = normalize(release.title)
return albums
.filter((a) => normalize(a.title) === title)
.sort((a, b) => {
const am = normalize(a.artist) === normalize(release.artist) ? 0 : 1
const bm = normalize(b.artist) === normalize(release.artist) ? 0 : 1
return am - bm
})
.slice(0, 20)
}

27
server/src/queue.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface SerialQueueOptions {
/** Minimum delay between task starts. 0 = unpaced. */
minIntervalMs?: number
}
export class SerialQueue {
private tail: Promise<unknown> = Promise.resolve()
private lastStart = 0
private minIntervalMs: number
constructor(opts: SerialQueueOptions = {}) {
this.minIntervalMs = opts.minIntervalMs ?? 0
}
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(async () => {
if (this.minIntervalMs > 0) {
const wait = this.lastStart + this.minIntervalMs - Date.now()
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
}
this.lastStart = Date.now()
return fn()
})
this.tail = result.catch(() => {})
return result
}
}

View File

@@ -0,0 +1,31 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { DiscogsReleaseFull } from './discogs.js'
/**
* Disk cache for full Discogs release payloads, keyed by release id.
* Cache read/write failures are best-effort; fetcher errors propagate so
* route-level Discogs error mapping still applies.
*/
export class ReleaseCache {
constructor(private dir: string) {}
async get(id: number, fetcher: () => Promise<DiscogsReleaseFull>): Promise<DiscogsReleaseFull> {
const file = path.join(this.dir, `${id}.json`)
if (existsSync(file)) {
try {
return JSON.parse(readFileSync(file, 'utf8')) as DiscogsReleaseFull
} catch {
// unreadable/corrupt — refetch below
}
}
const release = await fetcher()
try {
mkdirSync(this.dir, { recursive: true })
writeFileSync(file, JSON.stringify(release))
} catch {
// best-effort persistence
}
return release
}
}

64
server/src/ripstatus.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { DB } from './db.js'
import { isConfidentMatch, normalize } from './matcher.js'
export type RipStatus = 'ripped' | 'not_ripped'
/**
* Resolution order per spec:
* 1. manual rip_override (non-null) wins
* 2. else a stored match_link means ripped
* 3. else confident fuzzy match against the user's digital_albums
*/
export function resolveRipStatus(db: DB, userId: number, itemId: number): RipStatus {
const item = db
.prepare('SELECT id, title, artist, rip_override FROM collection_items WHERE id = ? AND user_id = ?')
.get(itemId, userId) as
| { id: number; title: string; artist: string; rip_override: number | null }
| undefined
if (!item) return 'not_ripped'
if (item.rip_override !== null && item.rip_override !== undefined) {
return item.rip_override === 1 ? 'ripped' : 'not_ripped'
}
const link = db.prepare('SELECT album_id FROM match_links WHERE item_id = ?').get(itemId)
if (link) return 'ripped'
const albums = db
.prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { title: string; artist: string }[]
return albums.some((a) => isConfidentMatch(item, a)) ? 'ripped' : 'not_ripped'
}
/**
* Batch resolution for list views: loads albums and match links once per
* user instead of per item (GET /api/collection is otherwise O(items x albums)).
* Resolution order matches resolveRipStatus.
*/
export function resolveRipStatusBatch(
db: DB,
userId: number,
items: { id: number; title: string; artist: string; rip_override: number | null }[]
): RipStatus[] {
const albums = db
.prepare('SELECT title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { title: string; artist: string }[]
const albumKeys = new Set(
albums.map((a) => `${normalize(a.title)}|${normalize(a.artist)}`)
)
const linked = new Set(
(
db.prepare('SELECT item_id FROM match_links WHERE user_id = ?').all(userId) as {
item_id: number
}[]
).map((r) => r.item_id)
)
return items.map((item) => {
if (item.rip_override !== null && item.rip_override !== undefined) {
return item.rip_override === 1 ? 'ripped' : 'not_ripped'
}
if (linked.has(item.id)) return 'ripped'
const key = `${normalize(item.title)}|${normalize(item.artist)}`
return albumKeys.has(key) ? 'ripped' : 'not_ripped'
})
}

View File

@@ -0,0 +1,168 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import {
hashPassword,
verifyPassword,
createUser,
getUserByUsername,
getUserById,
createSession,
getUserBySession,
deleteSession,
type UserRow,
} from '../auth.js'
import argon2 from 'argon2'
export const COOKIE_NAME = 'rs_session'
// Used to equalize response time for unknown usernames (timing-attack defense).
const DUMMY_HASH = argon2.hash('dummy-password-for-timing')
export interface PublicUser {
id: number
username: string
isAdmin: boolean
}
export function toPublicUser(u: { id: number; username: string; is_admin: number }): PublicUser {
return { id: u.id, username: u.username, isAdmin: u.is_admin === 1 }
}
function validateCredentials(username: unknown, password: unknown): string | null {
if (typeof username !== 'string' || username.length < 3 || username.length > 40) {
return 'username must be 3-40 characters'
}
if (typeof password !== 'string' || password.length < 8) {
return 'password must be at least 8 characters'
}
return null
}
export function cookieOpts() {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
maxAge: 30 * 24 * 60 * 60, // seconds
}
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = request.cookies[COOKIE_NAME]
if (token) {
const user = getUserBySession(request.server.db, token)
if (user) {
request.user = user
return
}
}
await reply.code(401).send({ error: 'unauthorized' })
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!request.user || request.user.is_admin !== 1) {
await reply.code(403).send({ error: 'forbidden' })
}
}
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/setup', async (request) => {
const count = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
return { needed: count === 0 }
})
app.post('/api/setup', async (request, reply) => {
const count = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
if (count > 0) return reply.code(403).send({ error: 'setup_already_done' })
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const invalid = validateCredentials(username, password)
if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid })
const passwordHash = await hashPassword(password as string)
const insertSetup = request.server.db.transaction((): 'taken' | { user: UserRow } => {
const current = (
request.server.db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
).n
if (current > 0) return 'taken'
const user = createUser(request.server.db, username as string, passwordHash, true)
request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id)
return { user }
})
const result = insertSetup.immediate()
if (result === 'taken') return reply.code(403).send({ error: 'setup_already_done' })
const token = createSession(request.server.db, result.user.id)
return reply
.setCookie(COOKIE_NAME, token, cookieOpts())
.code(200)
.send({ user: toPublicUser(result.user) })
})
app.post('/api/login', async (request, reply) => {
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const user = typeof username === 'string' ? getUserByUsername(request.server.db, username) : undefined
const hash = user?.password_hash ?? (await DUMMY_HASH)
const ok = typeof password === 'string' && (await verifyPassword(hash, password))
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
const oldToken = request.cookies[COOKIE_NAME]
if (oldToken) deleteSession(request.server.db, oldToken)
const token = createSession(request.server.db, user.id)
return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) })
})
app.post('/api/logout', async (request, reply) => {
const token = request.cookies[COOKIE_NAME]
if (token) deleteSession(request.server.db, token)
return reply.clearCookie(COOKIE_NAME, cookieOpts()).code(200).send({ ok: true })
})
app.get('/api/me', { preHandler: [requireAuth] }, async (request) => {
return { user: toPublicUser(request.user as UserRow) }
})
app.get('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
const users = request.server.db.prepare('SELECT * FROM users ORDER BY id').all() as UserRow[]
return { users: users.map(toPublicUser) }
})
app.post('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
const invalid = validateCredentials(username, password)
if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid })
if (getUserByUsername(request.server.db, username as string)) {
return reply.code(409).send({ error: 'username_taken' })
}
let user
try {
user = createUser(request.server.db, username as string, await hashPassword(password as string), false)
} catch (err: any) {
if (String(err.message).includes('UNIQUE constraint failed')) {
return reply.code(409).send({ error: 'username_taken' })
}
throw err
}
request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id)
return reply.code(200).send(toPublicUser(user))
})
app.delete(
'/api/users/:id',
{ preHandler: [requireAuth, requireAdmin] },
async (request, reply) => {
const id = Number((request.params as { id: string }).id)
if (id === (request.user as UserRow).id) {
return reply.code(400).send({ error: 'cannot_delete_self' })
}
if (!getUserById(request.server.db, id)) {
return reply.code(404).send({ error: 'not_found' })
}
request.server.db.prepare('DELETE FROM users WHERE id = ?').run(id)
return reply.code(200).send({ ok: true })
}
)
}

View File

@@ -0,0 +1,217 @@
import { FastifyInstance } from 'fastify'
import type { DB } from '../db.js'
import { cacheArtwork } from '../artwork.js'
import { resolveRipStatus, resolveRipStatusBatch, type RipStatus } from '../ripstatus.js'
import { requireAuth } from './authRoutes.js'
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
interface ItemRow {
id: number
user_id: number
discogs_release_id: number
title: string
artist: string
year: number | null
formats: string
genres: string
labels: string
tracklist: string
catno: string | null
country: string | null
cover_url: string | null
local_artwork_path: string | null
barcodes: string
rip_override: number | null
date_added: string
}
export function rowToItem(db: DB, row: ItemRow, ripStatus?: RipStatus) {
return {
id: row.id,
discogsReleaseId: row.discogs_release_id,
title: row.title,
artist: row.artist,
year: row.year,
formats: JSON.parse(row.formats),
genres: JSON.parse(row.genres),
labels: JSON.parse(row.labels),
tracklist: JSON.parse(row.tracklist),
catno: row.catno,
country: row.country,
artworkUrl: row.local_artwork_path ? `/artwork/${row.local_artwork_path}` : row.cover_url,
barcodes: JSON.parse(row.barcodes),
dateAdded: row.date_added,
ripOverride: row.rip_override === null ? null : row.rip_override === 1,
ripStatus: ripStatus ?? resolveRipStatus(db, row.user_id, row.id),
}
}
function getItem(db: DB, userId: number, id: number): ItemRow | undefined {
return db
.prepare('SELECT * FROM collection_items WHERE id = ? AND user_id = ?')
.get(id, userId) as ItemRow | undefined
}
export async function registerCollectionRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/collection', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const { releaseId, barcode, matchAlbumId } = (request.body ?? {}) as {
releaseId?: number
barcode?: unknown
matchAlbumId?: number
}
if (typeof releaseId !== 'number') return reply.code(400).send({ error: 'invalid_input' })
const db = request.server.db
const userId = request.user!.id
if (matchAlbumId != null) {
const album = db
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
.get(matchAlbumId, userId)
if (!album) return reply.code(404).send({ error: 'album_not_found' })
}
let release
try {
release = await request.server.releaseCache.get(releaseId, () => client.getRelease(releaseId))
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
const barcodeStr = typeof barcode === 'string' ? barcode : undefined
const artworkFile = release.coverUrl
? await cacheArtwork(request.server.config.artworkDir, release.coverUrl, request.server.fetchImpl)
: null
const barcodes = barcodeStr && !release.barcodes.includes(barcodeStr) ? [...release.barcodes, barcodeStr] : release.barcodes
let itemId: number
try {
const info = db
.prepare(
`INSERT INTO collection_items
(user_id, discogs_release_id, title, artist, year, formats, genres, labels, tracklist, catno, country, cover_url, local_artwork_path, barcodes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
userId,
release.id,
release.title,
release.artist,
release.year,
JSON.stringify(release.formats),
JSON.stringify(release.genres),
JSON.stringify(release.labels),
JSON.stringify(release.tracklist),
release.catno,
release.country,
release.coverUrl,
artworkFile,
JSON.stringify(barcodes)
)
itemId = Number(info.lastInsertRowid)
} catch (err: any) {
if (String(err.message).includes('UNIQUE constraint failed')) {
return reply.code(409).send({ error: 'duplicate' })
}
throw err
}
if (matchAlbumId != null) {
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)').run(
userId,
itemId,
matchAlbumId
)
}
const row = getItem(db, userId, itemId) as ItemRow
return reply.code(200).send(rowToItem(db, row))
})
app.get('/api/collection', { preHandler: [requireAuth] }, async (request) => {
const db = request.server.db
const userId = request.user!.id
const rows = db
.prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added DESC, id DESC')
.all(userId) as ItemRow[]
const statuses = resolveRipStatusBatch(db, userId, rows)
const items = rows.map((row, i) => rowToItem(db, row, statuses[i]))
const counts = {
total: items.length,
ripped: items.filter((i) => i.ripStatus === 'ripped').length,
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
}
const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string }
let filtered = items
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
if (ripped === 'ripped' || ripped === 'not_ripped') {
filtered = filtered.filter((i) => i.ripStatus === ripped)
}
if (q) {
const needle = q.toLowerCase()
filtered = filtered.filter(
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
)
}
return { items: filtered, counts }
})
app.get('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
if (!row) return reply.code(404).send({ error: 'not_found' })
return rowToItem(db, row)
})
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const info = db
.prepare('DELETE FROM collection_items WHERE id = ? AND user_id = ?')
.run(Number((request.params as { id: string }).id), request.user!.id)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
app.patch('/api/collection/:id/rip', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = request.user!.id
const id = Number((request.params as { id: string }).id)
const { ripped } = (request.body ?? {}) as { ripped?: boolean | null }
if (ripped !== true && ripped !== false && ripped !== null) {
return reply.code(400).send({ error: 'invalid_input' })
}
const value = ripped === null ? null : ripped ? 1 : 0
const info = db
.prepare('UPDATE collection_items SET rip_override = ? WHERE id = ? AND user_id = ?')
.run(value, id, userId)
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
return rowToItem(db, getItem(db, userId, id) as ItemRow)
})
app.post('/api/collection/:id/match', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = request.user!.id
const id = Number((request.params as { id: string }).id)
if (!getItem(db, userId, id)) return reply.code(404).send({ error: 'not_found' })
const { albumId } = (request.body ?? {}) as { albumId?: number | null }
if (albumId === null || albumId === undefined) {
db.prepare('DELETE FROM match_links WHERE user_id = ? AND item_id = ?').run(userId, id)
} else {
const album = db
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
.get(albumId, userId)
if (!album) return reply.code(404).send({ error: 'album_not_found' })
db.prepare(
`INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)
ON CONFLICT(user_id, item_id) DO UPDATE SET album_id = excluded.album_id`
).run(userId, id, albumId)
}
return rowToItem(db, getItem(db, userId, id) as ItemRow)
})
}

View File

@@ -0,0 +1,80 @@
import { FastifyInstance } from 'fastify'
import { requireAuth } from './authRoutes.js'
import { getSettings, subsonicConfigComplete } from './settingsRoutes.js'
export async function registerLibraryRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/library/sync', { preHandler: [requireAuth] }, async (request) => {
return request.server.sync.getState(request.user!.id)
})
app.post('/api/library/sync', { preHandler: [requireAuth] }, async (request, reply) => {
const s = getSettings(request.server.db, request.user!.id)
if (!subsonicConfigComplete(s)) {
return reply.code(409).send({ error: 'no_subsonic_config' })
}
request.server.sync.start(request.user!.id, {
url: s.subsonic_url as string,
username: s.subsonic_username as string,
password: s.subsonic_password as string,
})
return reply.code(202).send(request.server.sync.getState(request.user!.id))
})
app.get('/api/library/albums', { preHandler: [requireAuth] }, async (request) => {
const { q } = request.query as { q?: string }
const userId = request.user!.id
let rows: { id: number; subsonic_id: string; title: string; artist: string }[]
if (q) {
const needle = `%${q}%`
rows = request.server.db
.prepare(
`SELECT id, subsonic_id, title, artist FROM digital_albums
WHERE user_id = ? AND (title LIKE ? OR artist LIKE ?)
ORDER BY artist, title LIMIT 50`
)
.all(userId, needle, needle) as {
id: number
subsonic_id: string
title: string
artist: string
}[]
} else {
rows = request.server.db
.prepare(
`SELECT id, subsonic_id, title, artist FROM digital_albums
WHERE user_id = ? ORDER BY artist, title LIMIT 50`
)
.all(userId) as {
id: number
subsonic_id: string
title: string
artist: string
}[]
}
return {
albums: rows.map((r) => ({ id: r.id, subsonicId: r.subsonic_id, title: r.title, artist: r.artist })),
}
})
// Test-only seeding route so lookup/collection tests can populate digital
// albums without a live Subsonic server. Disabled outside tests.
app.post('/api/library/albums/test-seed', { preHandler: [requireAuth] }, async (request, reply) => {
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
return reply.code(404).send({ error: 'not_found' })
}
const { albums } = (request.body ?? {}) as {
albums?: { subsonicId: string; title: string; artist: string }[]
}
const userId = request.user!.id
const insert = request.server.db.prepare(
`INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist`
)
let inserted = 0
for (const a of albums ?? []) {
insert.run(userId, a.subsonicId, a.title, a.artist)
inserted++
}
return { inserted }
})
}

View File

@@ -0,0 +1,87 @@
import { FastifyInstance, FastifyRequest } from 'fastify'
import {
DiscogsClient,
DiscogsAuthError,
DiscogsRateLimitError,
DiscogsError,
} from '../discogs.js'
import { isConfidentMatch, candidateAlbums } from '../matcher.js'
import { requireAuth } from './authRoutes.js'
import { getSettings } from './settingsRoutes.js'
export function discogsErrorStatus(err: unknown): { code: number; body: Record<string, string> } {
if (err instanceof DiscogsAuthError) return { code: 502, body: { error: 'discogs_auth' } }
if (err instanceof DiscogsRateLimitError) return { code: 429, body: { error: 'discogs_rate_limited' } }
if (err instanceof DiscogsError) return { code: 502, body: { error: 'discogs_error' } }
return { code: 502, body: { error: 'discogs_unreachable' } }
}
export function discogsClientFor(request: FastifyRequest): DiscogsClient | null {
const s = getSettings(request.server.db, request.user!.id)
if (!s.discogs_token) return null
return new DiscogsClient(
s.discogs_token,
request.server.fetchImpl,
'https://api.discogs.com',
request.server.discogsQueue
)
}
export async function registerLookupRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/lookup/barcode/:code', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
try {
const candidates = await client.searchByBarcode((request.params as { code: string }).code)
if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' })
return { candidates }
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
app.get('/api/lookup/search', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const { q, format } = request.query as { q?: string; format?: string }
if (!q) return reply.code(400).send({ error: 'missing_query' })
try {
const candidates = await client.searchByText(q, format || undefined)
if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' })
return { candidates }
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
app.get('/api/lookup/release/:id', { preHandler: [requireAuth] }, async (request, reply) => {
const client = discogsClientFor(request)
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
const id = Number((request.params as { id: string }).id)
if (!Number.isInteger(id)) return reply.code(400).send({ error: 'invalid_input' })
try {
const release = await request.server.releaseCache.get(id, () => client.getRelease(id))
const db = request.server.db
const userId = request.user!.id
const duplicate = !!db
.prepare('SELECT id FROM collection_items WHERE user_id = ? AND discogs_release_id = ?')
.get(userId, id)
const albums = db
.prepare('SELECT id, title, artist FROM digital_albums WHERE user_id = ?')
.all(userId) as { id: number; title: string; artist: string }[]
const confident = albums.some((a) => isConfidentMatch(release, a))
const matchCandidates = confident ? [] : candidateAlbums(release, albums)
return {
release,
duplicate,
ripMatch: confident ? 'ripped' : matchCandidates.length > 0 ? 'ambiguous' : 'not_ripped',
matchCandidates,
}
} catch (err) {
const { code, body } = discogsErrorStatus(err)
return reply.code(code).send(body)
}
})
}

View File

@@ -0,0 +1,107 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import type { UserRow } from '../auth.js'
import type { DB } from '../db.js'
export interface SettingsRow {
user_id: number
discogs_token: string | null
subsonic_url: string | null
subsonic_username: string | null
subsonic_password: string | null
}
export function getSettings(db: DB, userId: number): SettingsRow {
const row = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as
| SettingsRow
| undefined
if (row) return row
db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(userId)
return db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as SettingsRow
}
export function settingsView(s: SettingsRow) {
return {
hasDiscogsToken: !!s.discogs_token,
discogsTokenMasked: s.discogs_token
? s.discogs_token.length > 5
? `****${s.discogs_token.slice(-5)}`
: '****'
: null,
subsonicUrl: s.subsonic_url,
subsonicUsername: s.subsonic_username,
hasSubsonicPassword: !!s.subsonic_password,
}
}
export function subsonicConfigComplete(s: SettingsRow): boolean {
return !!(s.subsonic_url && s.subsonic_username && s.subsonic_password)
}
export async function registerSettingsRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/settings', { preHandler: [requireAuth] }, async (request) => {
const s = getSettings(request.server.db, (request.user as UserRow).id)
return settingsView(s)
})
app.put('/api/settings', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = (request.user as UserRow).id
const s = getSettings(db, userId)
const body = (request.body ?? {}) as Record<string, string | undefined>
for (const value of Object.values(body)) {
if (value !== undefined && typeof value !== 'string') {
return reply.code(400).send({ error: 'invalid_input', detail: 'settings fields must be strings' })
}
}
const next = {
discogs_token: body.discogsToken !== undefined ? body.discogsToken || null : s.discogs_token,
subsonic_url: body.subsonicUrl !== undefined ? body.subsonicUrl || null : s.subsonic_url,
subsonic_username:
body.subsonicUsername !== undefined ? body.subsonicUsername || null : s.subsonic_username,
subsonic_password:
body.subsonicPassword !== undefined ? body.subsonicPassword || null : s.subsonic_password,
}
const candidate: SettingsRow = { ...s, ...next }
if (subsonicConfigComplete(candidate)) {
const client = new SubsonicClient({
url: candidate.subsonic_url as string,
username: candidate.subsonic_username as string,
password: candidate.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
try {
await client.ping()
} catch (err: any) {
if (err.code === 'unreachable') {
return reply.code(400).send({ error: 'subsonic_unreachable', detail: err.message })
}
return reply.code(400).send({ error: 'subsonic_auth', detail: err.message })
}
}
db.prepare(
`UPDATE settings SET discogs_token = ?, subsonic_url = ?, subsonic_username = ?, subsonic_password = ?
WHERE user_id = ?`
).run(
next.discogs_token,
next.subsonic_url,
next.subsonic_username,
next.subsonic_password,
userId
)
const updated = getSettings(db, userId)
if (subsonicConfigComplete(updated)) {
request.server.sync.start(userId, {
url: updated.subsonic_url as string,
username: updated.subsonic_username as string,
password: updated.subsonic_password as string,
})
}
return settingsView(updated)
})
}

99
server/src/subsonic.ts Normal file
View File

@@ -0,0 +1,99 @@
import crypto from 'node:crypto'
export interface SubsonicAlbum {
id: string
title: string
artist: string
}
export class SubsonicError extends Error {
constructor(
public code: 'auth' | 'unreachable' | 'api',
message: string
) {
super(message)
}
}
export interface SubsonicClientOptions {
url: string
username: string
password: string
fetchImpl?: typeof fetch
clientName?: string
}
export class SubsonicClient {
private base: string
private username: string
private password: string
private fetchImpl: typeof fetch
private clientName: string
constructor(opts: SubsonicClientOptions) {
this.base = opts.url.replace(/\/+$/, '')
this.username = opts.username
this.password = opts.password
this.fetchImpl = opts.fetchImpl ?? fetch
this.clientName = opts.clientName ?? 'record-shop'
}
private authParams(): Record<string, string> {
const salt = crypto.randomBytes(8).toString('hex')
const token = crypto.createHash('md5').update(this.password + salt).digest('hex')
return { u: this.username, t: token, s: salt, v: '1.16.1', c: this.clientName, f: 'json' }
}
private async request(endpoint: string, params: Record<string, string> = {}): Promise<any> {
let res: Response
try {
const url = new URL(`${this.base}/rest/${endpoint}`)
const search = { ...this.authParams(), ...params }
for (const [k, v] of Object.entries(search)) url.searchParams.set(k, v)
res = await this.fetchImpl(url.toString())
} catch {
throw new SubsonicError('unreachable', `could not reach ${this.base}`)
}
if (!res.ok) throw new SubsonicError('unreachable', `HTTP ${res.status} from ${this.base}`)
let body: any
try {
body = await res.json()
} catch {
throw new SubsonicError('api', 'malformed subsonic response')
}
const envelope = body['subsonic-response']
if (!envelope) throw new SubsonicError('api', 'malformed subsonic response')
if (envelope.status !== 'ok') {
const message: string = envelope.error?.message ?? 'subsonic request failed'
const code = envelope.error?.code === 40 || /credential|auth/i.test(message) ? 'auth' : 'api'
throw new SubsonicError(code, message)
}
return envelope
}
async ping(): Promise<void> {
await this.request('ping')
}
async getAllAlbums(
onProgress?: (albums: SubsonicAlbum[], done: number) => void
): Promise<SubsonicAlbum[]> {
const all: SubsonicAlbum[] = []
const pageSize = 500
for (let offset = 0; ; offset += pageSize) {
const envelope = await this.request('getAlbumList2', {
type: 'alphabeticalByName',
size: String(pageSize),
offset: String(offset),
})
const list: any[] = envelope.albumList2?.album ?? []
for (const a of list) {
if (!a || a.id == null) continue
all.push({ id: String(a.id), title: a.name ?? a.title ?? '', artist: a.artist ?? '' })
}
onProgress?.(all, all.length)
if (list.length < pageSize || all.length > 20000) break
}
return all
}
}

72
server/src/sync.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { DB } from './db.js'
import { SubsonicClient, SubsonicError } from './subsonic.js'
export interface SyncState {
status: 'idle' | 'running' | 'done' | 'error'
error: string | null
lastSyncedAt: string | null
albums: number
}
export function initialSyncState(): SyncState {
return { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
}
export class SyncManager {
private states = new Map<number, SyncState>()
constructor(
private db: DB,
private fetchImpl: typeof fetch
) {}
getState(userId: number): SyncState {
return this.states.get(userId) ?? initialSyncState()
}
/** Returns false when a sync is already running. */
start(userId: number, config: { url: string; username: string; password: string }): boolean {
const state = this.getState(userId)
if (state.status === 'running') return false
this.states.set(userId, { ...state, status: 'running', error: null })
void this.run(userId, config)
return true
}
private async run(
userId: number,
config: { url: string; username: string; password: string }
): Promise<void> {
const client = new SubsonicClient({ ...config, fetchImpl: this.fetchImpl })
try {
const albums = await client.getAllAlbums()
const seenIds = new Set(albums.map((a) => a.id))
const upsert = this.db.prepare(
`INSERT INTO digital_albums (user_id, subsonic_id, title, artist) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, subsonic_id) DO UPDATE SET title = excluded.title, artist = excluded.artist`
)
const selectAll = this.db.prepare('SELECT id, subsonic_id FROM digital_albums WHERE user_id = ?')
const deleteById = this.db.prepare('DELETE FROM digital_albums WHERE id = ?')
const apply = this.db.transaction(() => {
for (const r of albums) upsert.run(userId, r.id, r.title, r.artist)
for (const row of selectAll.all(userId) as { id: number; subsonic_id: string }[]) {
if (!seenIds.has(row.subsonic_id)) deleteById.run(row.id)
}
})
apply()
this.states.set(userId, {
status: 'done',
error: null,
lastSyncedAt: new Date().toISOString(),
albums: albums.length,
})
} catch (err) {
const state = this.getState(userId)
this.states.set(userId, {
...state,
status: 'error',
error: err instanceof SubsonicError ? err.message : 'sync failed',
})
}
}
}

17
server/test/app.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { openDatabase } from '../src/db.js'
import { testConfig } from './helpers.js'
const config = testConfig()
describe('GET /api/health', () => {
it('returns ok', async () => {
const db = openDatabase(':memory:')
const app = await buildApp({ db, config })
const res = await app.inject({ method: 'GET', url: '/api/health' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
await app.close()
})
})

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { cacheArtwork } from '../src/artwork.js'
function tempDir(): string {
return mkdtempSync(path.join(tmpdir(), 'rs-artwork-'))
}
function imageFetch(calls: { count: number }): typeof fetch {
return (async () => {
calls.count++
return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})
}) as typeof fetch
}
describe('cacheArtwork', () => {
it('downloads and stores by url hash with content-type extension', async () => {
const dir = tempDir()
try {
const calls = { count: 0 }
const file = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', imageFetch(calls))
expect(file).toMatch(/^[0-9a-f]{64}\.jpg$/)
expect(existsSync(path.join(dir, file as string))).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('second call for same url does not refetch', async () => {
const dir = tempDir()
try {
const calls = { count: 0 }
const fetcher = imageFetch(calls)
await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher)
const again = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher)
expect(calls.count).toBe(1)
expect(again).not.toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('returns null for empty url and fetch failures without throwing', async () => {
const dir = tempDir()
try {
expect(await cacheArtwork(dir, '', imageFetch({ count: 0 }))).toBeNull()
const failing = (async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/y.jpg', failing)).toBeNull()
const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/y.jpg', notFound)).toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stores non-jpeg types with correct extension', async () => {
const dir = tempDir()
try {
const fetcher = (async () =>
new Response(new Uint8Array([0x89, 0x50]), {
status: 200,
headers: { 'content-type': 'image/png' },
})) as typeof fetch
const file = await cacheArtwork(dir, 'https://img.discogs.com/a.png', fetcher)
expect(file).toMatch(/\.png$/)
expect(readFileSync(path.join(dir, file as string)).length).toBeGreaterThan(0)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('returns null when the response body fails mid-download', async () => {
const dir = tempDir()
try {
const fetcher = (async () =>
new Response(new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([0xff]))
controller.error(new TypeError('network error mid-body'))
},
}), { status: 200, headers: { 'content-type': 'image/jpeg' } })) as unknown as typeof fetch
expect(await cacheArtwork(dir, 'https://x/abort.jpg', fetcher)).toBeNull()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

50
server/test/auth.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
import {
hashPassword,
verifyPassword,
createUser,
createSession,
getUserBySession,
deleteSession,
} from '../src/auth.js'
describe('passwords', () => {
it('hashes and verifies', async () => {
const hash = await hashPassword('correct horse battery staple')
expect(hash).not.toContain('correct')
expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true)
expect(await verifyPassword(hash, 'wrong')).toBe(false)
})
})
describe('sessions', () => {
it('creates a user and resolves a valid session, rejects expired', async () => {
const db = openDatabase(':memory:')
const user = createUser(db, 'sam', await hashPassword('password123'), true)
expect(user.is_admin).toBe(1)
const token = await createSession(db, user.id)
const resolved = getUserBySession(db, token, () => Date.now())
expect(resolved?.username).toBe('sam')
const expiredToken = await createSession(db, user.id)
db.prepare('UPDATE sessions SET expires_at = ? WHERE token = ?').run(
'2000-01-01T00:00:00.000Z',
expiredToken
)
expect(getUserBySession(db, expiredToken, () => Date.now())).toBeNull()
expect(
db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number }
).toEqual({ n: 1 })
deleteSession(db, token)
expect(getUserBySession(db, token, () => Date.now())).toBeNull()
})
it('rejects duplicate usernames', async () => {
const db = openDatabase(':memory:')
createUser(db, 'sam', 'hash1', false)
expect(() => createUser(db, 'sam', 'hash2', false)).toThrow()
})
})

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, getCookie, auth, loginAs } from './helpers.js'
describe('auth routes', () => {
it('setup creates admin when no users exist, then is closed', async () => {
const app = await buildTestApp()
const status = await app.inject({ method: 'GET', url: '/api/setup' })
expect(status.json()).toEqual({ needed: true })
const cookie = await setupAdmin(app)
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(me.statusCode).toBe(200)
expect(me.json()).toEqual({ user: { id: 1, username: 'admin', isAdmin: true } })
const again = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'x', password: 'password123' },
})
expect(again.statusCode).toBe(403)
await app.close()
})
it('setup validates input', async () => {
const app = await buildTestApp()
const bad = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'ab', password: 'short' },
})
expect(bad.statusCode).toBe(400)
await app.close()
})
it('login and logout', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const login = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'adminpass123' },
})
expect(login.statusCode).toBe(200)
const cookie = getCookie(login)
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(me.statusCode).toBe(200)
await app.inject({ method: 'POST', url: '/api/logout', ...auth(cookie) })
const after = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
expect(after.statusCode).toBe(401)
await app.close()
})
it('login rejects wrong password', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const res = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'wrongpass123' },
})
expect(res.statusCode).toBe(401)
await app.close()
})
it('protected route requires auth', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/me' })
expect(res.statusCode).toBe(401)
await app.close()
})
it('re-login invalidates the previous session', async () => {
const app = await buildTestApp()
await setupAdmin(app)
const first = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'admin', password: 'adminpass123' },
})
const firstCookie = getCookie(first)
const second = await app.inject({
method: 'POST',
url: '/api/login',
...auth(firstCookie),
payload: { username: 'admin', password: 'adminpass123' },
})
expect(second.statusCode).toBe(200)
const oldStale = await app.inject({ method: 'GET', url: '/api/me', ...auth(firstCookie) })
expect(oldStale.statusCode).toBe(401)
const newCookie = getCookie(second)
const fresh = await app.inject({ method: 'GET', url: '/api/me', ...auth(newCookie) })
expect(fresh.statusCode).toBe(200)
await app.close()
})
})
describe('user admin', () => {
async function adminApp() {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
return { app, cookie }
}
it('admin creates a user and lists users', async () => {
const { app, cookie } = await adminApp()
const created = await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
expect(created.statusCode).toBe(200)
expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false })
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob'])
await app.close()
})
it('non-admin cannot list or create users', async () => {
const { app, cookie } = await adminApp()
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) })
expect(list.statusCode).toBe(403)
const create = await app.inject({
method: 'POST',
url: '/api/users',
...auth(bobCookie),
payload: { username: 'eve', password: 'evepass123' },
})
expect(create.statusCode).toBe(403)
await app.close()
})
it('cannot delete self; deleting another user works and cascades settings', async () => {
const { app, cookie } = await adminApp()
await loginAs(app, cookie, 'bob', 'bobpass123')
const settingsCount = () =>
(app.db.prepare('SELECT COUNT(*) AS n FROM settings WHERE user_id = 2').get() as { n: number }).n
const sessionsCount = () =>
(app.db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = 2').get() as { n: number }).n
expect(settingsCount()).toBe(1)
expect(sessionsCount()).toBe(1)
const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) })
expect(selfDelete.statusCode).toBe(400)
const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) })
expect(del.statusCode).toBe(200)
expect(settingsCount()).toBe(0)
expect(sessionsCount()).toBe(0)
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
expect(list.json().users).toHaveLength(1)
await app.close()
})
it('rejects duplicate username', async () => {
const { app, cookie } = await adminApp()
await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
const again = await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'otherpass123' },
})
expect(again.statusCode).toBe(409)
await app.close()
})
})

View File

@@ -0,0 +1,215 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth, getCookie } from './helpers.js'
import { discogsReleaseFixture } from './fixtures.js'
function discogsStub(): typeof fetch {
return (async (input: any) => {
const url = String(input)
if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('img.discogs.com')) {
return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithToken() {
const app = await buildTestApp(discogsStub())
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'testtoken' },
})
return { app, cookie }
}
describe('collection routes', () => {
it('adds a release from discogs, returns item with artwork and rip status', async () => {
const { app, cookie } = await appWithToken()
const res = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001, barcode: '5021592210629' },
})
expect(res.statusCode).toBe(200)
const item = res.json()
expect(item).toMatchObject({
discogsReleaseId: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
catno: 'ZENCD012',
barcodes: ['5021592210629'],
ripOverride: null,
ripStatus: 'not_ripped',
})
expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/)
await app.close()
})
it('rejects duplicate add with 409', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const again = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
expect(again.statusCode).toBe(409)
expect(again.json()).toEqual({ error: 'duplicate' })
await app.close()
})
it('lists items with counts and filters (format, ripped, q)', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 })
expect(all.json().items).toHaveLength(1)
const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) })
expect(cd.json().items).toHaveLength(1)
const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) })
expect(vinyl.json().items).toHaveLength(0)
const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) })
expect(ripped.json().items).toHaveLength(0)
const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) })
expect(notRipped.json().items).toHaveLength(1)
const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) })
expect(q.json().items).toHaveLength(1)
const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) })
expect(qMiss.json().items).toHaveLength(0)
await app.close()
})
it('detail, rip override, match link, re-match search, delete', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
const id = added.json().id as number
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(detail.statusCode).toBe(200)
expect(detail.json().tracklist).toEqual([
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
])
// manual rip override
const rip = await app.inject({
method: 'PATCH',
url: `/api/collection/${id}/rip`,
...auth(cookie),
payload: { ripped: true },
})
expect(rip.json().ripOverride).toBe(true)
expect(rip.json().ripStatus).toBe('ripped')
const clear = await app.inject({
method: 'PATCH',
url: `/api/collection/${id}/rip`,
...auth(cookie),
payload: { ripped: null },
})
expect(clear.json().ripOverride).toBeNull()
expect(clear.json().ripStatus).toBe('not_ripped')
// match link (simulates confirmed ambiguous match)
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] },
})
const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) })
const albumId = albums.json().albums[0].id as number
const linked = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId },
})
expect(linked.json().ripStatus).toBe('ripped')
// clear link
const unlinked = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId: null },
})
expect(unlinked.json().ripStatus).toBe('not_ripped')
const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) })
expect(del.statusCode).toBe(200)
const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
expect(after.statusCode).toBe(404)
await app.close()
})
it('rejects match to another users album', async () => {
const { app, cookie } = await appWithToken()
const added = await app.inject({
method: 'POST',
url: '/api/collection',
...auth(cookie),
payload: { releaseId: 1001 },
})
const id = added.json().id as number
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] },
})
// album id 99 does not exist for this user
const res = await app.inject({
method: 'POST',
url: `/api/collection/${id}/match`,
...auth(cookie),
payload: { albumId: 99 },
})
expect(res.statusCode).toBe(404)
await app.close()
})
it('second user cannot see first users items', async () => {
const { app, cookie } = await appWithToken()
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
await app.inject({
method: 'POST',
url: '/api/users',
...auth(cookie),
payload: { username: 'bob', password: 'bobpass123' },
})
const bobLogin = await app.inject({
method: 'POST',
url: '/api/login',
payload: { username: 'bob', password: 'bobpass123' },
})
const bobCookie = getCookie(bobLogin)
const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) })
expect(list.json().items).toHaveLength(0)
await app.close()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { loadConfig } from '../src/config.js'
function tempDir(): string {
return mkdtempSync(path.join(tmpdir(), 'rs-config-'))
}
describe('loadConfig', () => {
it('creates data + artwork dirs and returns paths', () => {
const dir = tempDir()
try {
const cfg = loadConfig({ DATA_DIR: dir })
expect(cfg.dataDir).toBe(dir)
expect(cfg.dbPath).toBe(path.join(dir, 'record-shop.db'))
expect(cfg.port).toBe(3000)
expect(existsSync(path.join(dir, 'artwork-cache'))).toBe(true)
expect(cfg.sessionSecret).toMatch(/^[0-9a-f]{64}$/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('persists and reuses the session secret', () => {
const dir = tempDir()
try {
const a = loadConfig({ DATA_DIR: dir })
const b = loadConfig({ DATA_DIR: dir })
expect(a.sessionSecret).toBe(b.sessionSecret)
expect(readFileSync(path.join(dir, 'session-secret'), 'utf8')).toBe(a.sessionSecret)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('honours PORT', () => {
const dir = tempDir()
try {
expect(loadConfig({ DATA_DIR: dir, PORT: '8080' }).port).toBe(8080)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

37
server/test/db.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { openDatabase } from '../src/db.js'
describe('openDatabase', () => {
it('creates the full schema', () => {
const db = openDatabase(':memory:')
const tables = (
db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as {
name: string
}[]
).map((r) => r.name)
for (const t of [
'users',
'sessions',
'settings',
'collection_items',
'digital_albums',
'match_links',
'app_meta',
]) {
expect(tables).toContain(t)
}
})
it('enforces unique (user_id, discogs_release_id)', () => {
const db = openDatabase(':memory:')
db.prepare(
"INSERT INTO users (username, password_hash, is_admin) VALUES ('sam', 'x', 1)"
).run()
const insert = db.prepare(
`INSERT INTO collection_items (user_id, discogs_release_id, title, artist)
VALUES (1, 100, 'Album', 'Artist')`
)
insert.run()
expect(() => insert.run()).toThrow()
})
})

119
server/test/discogs.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import {
DiscogsClient,
DiscogsAuthError,
DiscogsRateLimitError,
mapSearchResult,
mapRelease,
} from '../src/discogs.js'
import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function stubFetch(routes: (url: string, init?: any) => Response): typeof fetch {
return (async (input: any, init?: any) => routes(String(input), init)) as typeof fetch
}
describe('DiscogsClient', () => {
it('searches by barcode with token and maps results', async () => {
const seen: string[] = []
const seenAuth: string[] = []
const c = new DiscogsClient(
'testtoken',
stubFetch((_url, init) => {
seen.push(_url)
seenAuth.push(String(new Headers(init?.headers).get('Authorization')))
return jsonResponse(discogsSearchFixture)
})
)
const results = await c.searchByBarcode('5021592210629')
expect(seen[0]).toContain('/database/search')
expect(seen[0]).toContain('barcode=5021592210629')
expect(seen[0]).toContain('type=release')
expect(seenAuth[0]).toBe('Discogs token=testtoken')
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD', 'Album'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: 'https://img.discogs.com/small1.jpg',
})
})
it('searches by text with optional format filter', async () => {
const seen: string[] = []
const c = new DiscogsClient(
't',
stubFetch((url) => {
seen.push(url)
return jsonResponse(discogsSearchFixture)
})
)
await c.searchByText('motion', 'Vinyl')
expect(seen[0]).toContain('q=motion')
expect(seen[0]).toContain('format=Vinyl')
})
it('throws DiscogsAuthError on 401', async () => {
const c = new DiscogsClient('bad', stubFetch(() => jsonResponse({ message: 'bad' }, 401)))
await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsAuthError)
})
it('throws DiscogsRateLimitError on 429', async () => {
const c = new DiscogsClient('t', stubFetch(() => jsonResponse({}, 429)))
await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsRateLimitError)
})
it('fetches full release with barcode identifiers', async () => {
const c = new DiscogsClient(
't',
stubFetch((url) => {
expect(url).toContain('/releases/1001')
return jsonResponse(discogsReleaseFixture)
})
)
const release = await c.getRelease(1001)
expect(release.title).toBe('Motion')
expect(release.barcodes).toEqual(['5021592210629'])
expect(release.coverUrl).toBe('https://img.discogs.com/full1.jpg')
})
})
describe('mappers', () => {
it('splits search title into artist/title and parses year', () => {
const first = discogsSearchFixture.results[0]!
const mapped = mapSearchResult(first)
expect(mapped.artist).toBe('The Cinematic Orchestra')
expect(mapped.title).toBe('Motion')
expect(mapped.year).toBe(1999)
})
it('maps full release', () => {
const mapped = mapRelease(discogsReleaseFixture)
expect(mapped.formats).toEqual(['CD'])
expect(mapped.labels).toEqual(['Ninja Tune'])
expect(mapped.tracklist).toEqual([
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
])
})
it('does not throw on malformed payloads', () => {
expect(() => mapSearchResult(null)).not.toThrow()
expect(() => mapRelease(undefined)).not.toThrow()
expect(mapRelease({ tracklist: [null, { title: 'X' }] }).tracklist).toEqual([{ position: '', title: 'X' }])
expect(mapSearchResult({ format: [null, 'CD'] }).formats).toEqual(['CD'])
expect(new DiscogsAuthError().name).toBe('DiscogsAuthError')
expect(new DiscogsRateLimitError().name).toBe('DiscogsRateLimitError')
})
})

45
server/test/fixtures.ts Normal file
View File

@@ -0,0 +1,45 @@
export const discogsSearchFixture = {
results: [
{
id: 1001,
type: 'release',
title: 'The Cinematic Orchestra - Motion',
year: '1999',
format: ['CD', 'Album'],
label: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
cover_image: 'https://img.discogs.com/big1.jpg',
thumb: 'https://img.discogs.com/small1.jpg',
},
{
id: 1002,
type: 'release',
title: 'The Cinematic Orchestra - Motion',
year: '1999',
format: ['Vinyl', '2xLP'],
label: ['Ninja Tune'],
country: 'UK',
catno: 'ZEN012',
cover_image: 'https://img.discogs.com/big2.jpg',
thumb: 'https://img.discogs.com/small2.jpg',
},
],
}
export const discogsReleaseFixture = {
id: 1001,
title: 'Motion',
artists: [{ name: 'The Cinematic Orchestra' }],
year: 1999,
formats: [{ name: 'CD', qty: '1' }],
labels: [{ name: 'Ninja Tune', catno: 'ZENCD012' }],
genres: ['Electronic', 'Jazz'],
country: 'UK',
images: [{ uri: 'https://img.discogs.com/full1.jpg' }],
tracklist: [
{ position: '1', title: 'Overture' },
{ position: '2', title: 'Theme de Yoyo' },
],
identifiers: [{ type: 'Barcode', value: '5021592210629' }],
}

71
server/test/helpers.ts Normal file
View File

@@ -0,0 +1,71 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { openDatabase, type DB } from '../src/db.js'
import { buildApp } from '../src/app.js'
import type { Config } from '../src/config.js'
import type { FastifyInstance } from 'fastify'
export function testConfig(): Config {
// artworkDir must be a real writable directory (cached images land there)
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
// dataDir must be a real writable directory (release-cache payloads land there)
const dataDir = mkdtempSync(path.join(tmpdir(), 'rs-data-'))
return {
dataDir,
artworkDir,
dbPath: ':memory:',
port: 0,
sessionSecret: 'test-secret-test-secret-test-secret-1234',
}
}
export async function buildTestApp(fetchImpl?: typeof fetch): Promise<FastifyInstance> {
const db = openDatabase(':memory:')
return buildApp({ db, config: testConfig(), fetchImpl })
}
export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise<FastifyInstance> {
return buildApp({ db, config: testConfig(), fetchImpl })
}
/** Runs /api/setup to create admin 'admin' / 'adminpass123'. Returns session token value. */
export async function setupAdmin(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/api/setup',
payload: { username: 'admin', password: 'adminpass123' },
})
if (res.statusCode !== 200) throw new Error(`setup failed: ${res.body}`)
return getCookie(res)
}
/** Creates and logs in a non-admin user via /api/users + /api/login. Returns session token value. */
export async function loginAs(
app: FastifyInstance,
adminToken: string,
username: string,
password: string
): Promise<string> {
const created = await app.inject({
method: 'POST',
url: '/api/users',
...auth(adminToken),
payload: { username, password },
})
if (created.statusCode !== 200) throw new Error(`user create failed: ${created.body}`)
const login = await app.inject({ method: 'POST', url: '/api/login', payload: { username, password } })
if (login.statusCode !== 200) throw new Error(`login failed: ${login.body}`)
return getCookie(login)
}
export function getCookie(res: { cookies: { name: string; value: string }[] }): string {
const cookie = res.cookies.find((c) => c.name === 'rs_session')
if (!cookie) throw new Error('no rs_session cookie in response')
return cookie.value
}
/** inject option spread for an authenticated request: `app.inject({ ..., ...auth(token) })` */
export function auth(sessionToken: string): { cookies: Record<string, string> } {
return { cookies: { rs_session: sessionToken } }
}

206
server/test/library.test.ts Normal file
View File

@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, loginAs, auth } from './helpers.js'
function albumPage(count: number, offset: number) {
return {
'subsonic-response': {
status: 'ok',
albumList2: {
album: Array.from({ length: count }, (_, i) => ({
id: offset + i + 1,
name: `Album ${offset + i + 1}`,
artist: `Artist ${Math.floor((offset + i) / 10)}`,
})),
},
},
}
}
function subsonicStub(): typeof fetch {
return (async (input: any) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.pathname.endsWith('/rest/getAlbumList2')) {
const offset = Number(url.searchParams.get('offset') ?? 0)
return new Response(
JSON.stringify(offset === 0 ? albumPage(500, 0) : albumPage(3, 500)),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
return new Response('nope', { status: 404 })
}) as typeof fetch
}
async function appWithSubsonic() {
const app = await buildTestApp(subsonicStub())
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
return { app, cookie }
}
async function waitForDone(app: any, cookie: string, timeoutMs = 2000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const state = (await app.inject({ method: 'GET', url: '/api/library/sync', ...auth(cookie) })).json()
if (state.status !== 'running') return state
await new Promise((r) => setTimeout(r, 10))
}
throw new Error('sync did not finish in time')
}
describe('library sync', () => {
it('sync paginates subsonic and caches albums', async () => {
const { app, cookie } = await appWithSubsonic()
const start = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
expect(start.statusCode).toBe(202)
const state = await waitForDone(app, cookie)
expect(state.status).toBe('done')
expect(state.albums).toBe(503)
expect(state.lastSyncedAt).toBeTruthy()
const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album 7', ...auth(cookie) })
expect(albums.json().albums.length).toBeGreaterThan(0)
// idempotent re-sync: count stays 503 (upsert, no duplicates)
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const second = await waitForDone(app, cookie)
expect(second.albums).toBe(503)
await app.close()
})
it('returns 409 without subsonic config', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
expect(res.statusCode).toBe(409)
expect(res.json()).toEqual({ error: 'no_subsonic_config' })
await app.close()
})
it('reports error state when album fetch fails after valid ping', async () => {
// ping succeeds (settings validation passes) but getAlbumList2 fails (sync errors)
const fetcher = (async (input: any) => {
if (String(input).includes('/rest/ping')) {
return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const app = await buildTestApp(fetcher)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://flaky.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const state = await waitForDone(app, cookie)
expect(state.status).toBe('error')
expect(state.error).toBeTruthy()
await app.close()
})
it('albums search is scoped per user', async () => {
const { app, cookie } = await appWithSubsonic()
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
await waitForDone(app, cookie)
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
const empty = await app.inject({ method: 'GET', url: '/api/library/albums?q=Album', ...auth(bobCookie) })
expect(empty.json().albums).toHaveLength(0)
await app.close()
})
it('test-seed route inserts albums directly (used by lookup/collection tests)', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
expect(res.statusCode).toBe(200)
expect(res.json().inserted).toBe(1)
await app.close()
})
it('saving subsonic settings triggers a first sync', async () => {
const { app, cookie } = await appWithSubsonic()
const state = await waitForDone(app, cookie)
expect(state.status).toBe('done')
expect(state.albums).toBe(503)
await app.close()
})
it('re-sync removes albums that disappeared from subsonic', async () => {
// sync 1 (from settings PUT): 1 album 'Kept Album'
// sync 2 (explicit POST): 1 album 'Only Album' — 'Kept Album' must be gone
let syncCount = 0
const shrinking = (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')) {
syncCount++
const album =
syncCount === 1
? { id: 1, name: 'Kept Album', artist: 'Artist A' }
: { id: 9, name: 'Only Album', artist: 'Artist B' }
return new Response(
JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [album] } } }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
}
return new Response('nope', { status: 404 })
}) as typeof fetch
const app = await buildTestApp(shrinking)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: 'pass' },
})
const first = await waitForDone(app, cookie)
expect(first.albums).toBe(1)
expect(await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) }).then((r) => r.json())).toMatchObject({ albums: [expect.objectContaining({ title: 'Kept Album' })] })
await app.inject({ method: 'POST', url: '/api/library/sync', ...auth(cookie) })
const second = await waitForDone(app, cookie)
expect(second.albums).toBe(1)
const stale = await app.inject({ method: 'GET', url: '/api/library/albums?q=Kept', ...auth(cookie) })
expect(stale.json().albums).toHaveLength(0) // stale album removed
const kept = await app.inject({ method: 'GET', url: '/api/library/albums?q=Only', ...auth(cookie) })
expect(kept.json().albums).toHaveLength(1)
await app.close()
})
})

193
server/test/lookup.test.ts Normal file
View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js'
function stubFetch(routes: (url: string) => Response): typeof fetch {
return (async (input: any) => routes(String(input))) as typeof fetch
}
function discogsStub(): typeof fetch {
return stubFetch((url) => {
if (url.includes('/database/search')) {
return new Response(JSON.stringify(discogsSearchFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/releases/1001')) {
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('nope', { status: 404 })
})
}
async function appWithToken(discogsFetch: typeof fetch) {
const app = await buildTestApp(discogsFetch)
const cookie = await setupAdmin(app)
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'testtoken' },
})
return { app, cookie }
}
describe('GET /api/lookup/barcode/:code', () => {
it('returns candidates on match', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/5021592210629', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.candidates).toHaveLength(2)
expect(body.candidates[0]).toMatchObject({
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
})
await app.close()
})
it('returns 404 not_found when discogs has zero results', async () => {
const empty = stubFetch((url) =>
url.includes('/database/search')
? new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
: new Response('nope', { status: 404 })
)
const { app, cookie } = await appWithToken(empty)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
it('returns 409 no_discogs_token when token unset', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(409)
expect(res.json()).toEqual({ error: 'no_discogs_token' })
await app.close()
})
it('maps discogs auth failure to 502 discogs_auth', async () => {
const unauthorized = stubFetch(() => new Response('bad token', { status: 401 }))
const { app, cookie } = await appWithToken(unauthorized)
const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'discogs_auth' })
await app.close()
})
})
describe('GET /api/lookup/search', () => {
it('returns candidates for text query with format filter', async () => {
const seen: string[] = []
const { app, cookie } = await appWithToken(
stubFetch((url) => {
seen.push(url)
return new Response(JSON.stringify(discogsSearchFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
)
const res = await app.inject({ method: 'GET', url: '/api/lookup/search?q=motion&format=Vinyl', ...auth(cookie) })
expect(res.statusCode).toBe(200)
expect(seen[0]).toContain('q=motion')
expect(seen[0]).toContain('format=Vinyl')
expect(res.json().candidates).toHaveLength(2)
await app.close()
})
})
describe('GET /api/lookup/release/:id', () => {
it('returns full release with duplicate and ripMatch', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.release).toMatchObject({
id: 1001,
title: 'Motion',
artist: 'The Cinematic Orchestra',
barcodes: ['5021592210629'],
})
expect(body.duplicate).toBe(false)
expect(body.ripMatch).toBe('not_ripped')
expect(body.matchCandidates).toEqual([])
await app.close()
})
it('caches release payloads on disk (second lookup costs no discogs call)', async () => {
let releaseCalls = 0
const { app, cookie } = await appWithToken(
stubFetch((url) => {
if (url.includes('/releases/1001')) {
releaseCalls++
return new Response(JSON.stringify(discogsReleaseFixture), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('nope', { status: 404 })
})
)
await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(releaseCalls).toBe(1)
await app.close()
})
it('rejects non-integer release ids with 400', async () => {
const { app, cookie } = await appWithToken(discogsStub())
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/abc', ...auth(cookie) })
expect(res.statusCode).toBe(400)
expect(res.json()).toEqual({ error: 'invalid_input' })
await app.close()
})
it('reports duplicate when release already in collection', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.json().duplicate).toBe(true)
await app.close()
})
it('reports ripped on confident match against digital albums', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] },
})
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
expect(res.json().ripMatch).toBe('ripped')
await app.close()
})
it('reports ambiguous with matchCandidates on same-title different-artist', async () => {
const { app, cookie } = await appWithToken(discogsStub())
await app.inject({
method: 'POST',
url: '/api/library/albums/test-seed',
...auth(cookie),
payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'Somebody Else' }] },
})
const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) })
const body = res.json()
expect(body.ripMatch).toBe('ambiguous')
expect(body.matchCandidates).toHaveLength(1)
expect(body.matchCandidates[0]).toMatchObject({ title: 'Motion', artist: 'Somebody Else' })
await app.close()
})
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { normalize, isConfidentMatch, candidateAlbums } from '../src/matcher.js'
describe('normalize', () => {
it('lowercases, strips punctuation, articles, accents, extra whitespace', () => {
expect(normalize('The Cinematic Orchestra!')).toBe('cinematic orchestra')
expect(normalize(' Blur: The Best of… ')).toBe('blur best of')
expect(normalize('Björk — Début')).toBe('bjork debut')
expect(normalize('A Tribe Called Quest')).toBe('tribe called quest')
expect(normalize('Ænima')).toBe('aenima')
})
})
describe('isConfidentMatch', () => {
it('matches when normalized artist AND title are equal', () => {
expect(
isConfidentMatch(
{ title: 'Motion!', artist: 'The Cinematic Orchestra' },
{ title: 'Motion', artist: 'Cinematic Orchestra' }
)
).toBe(true)
expect(
isConfidentMatch(
{ title: 'Motion', artist: 'Massive Attack' },
{ title: 'Motion', artist: 'The Cinematic Orchestra' }
)
).toBe(false)
expect(
isConfidentMatch(
{ title: 'Blue Lines', artist: 'Massive Attack' },
{ title: 'Motion', artist: 'Massive Attack' }
)
).toBe(false)
})
})
describe('candidateAlbums', () => {
const albums = [
{ id: 1, title: 'Motion', artist: 'Someone Else' },
{ id: 2, title: 'Motion', artist: 'The Cinematic Orchestra' },
{ id: 3, title: 'Other Album', artist: 'The Cinematic Orchestra' },
]
it('returns same-title albums with artist matches first', () => {
const candidates = candidateAlbums(
{ title: 'Motion', artist: 'Cinematic Orchestra' },
albums
)
expect(candidates.map((c) => c.id)).toEqual([2, 1])
})
it('caps candidates at 20', () => {
const many = Array.from({ length: 50 }, (_, i) => ({
id: i,
title: 'Motion',
artist: `Artist ${i}`,
}))
expect(candidateAlbums({ title: 'Motion', artist: 'zzz' }, many)).toHaveLength(20)
})
})

42
server/test/queue.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest'
import { SerialQueue } from '../src/queue.js'
describe('SerialQueue', () => {
it('runs tasks strictly in order even when they resolve out of order', async () => {
const q = new SerialQueue()
const order: number[] = []
const slow = () => new Promise((r) => setTimeout(r, 20)).then(() => order.push(1))
const fast = () => Promise.resolve().then(() => order.push(2))
await Promise.all([q.run(slow), q.run(fast)])
expect(order).toEqual([1, 2])
})
it('continues after a failing task', async () => {
const q = new SerialQueue()
await expect(
q.run(async () => {
throw new Error('boom')
})
).rejects.toThrow('boom')
const result = await q.run(async () => 'ok')
expect(result).toBe('ok')
})
it('paces call starts at least minIntervalMs apart', async () => {
vi.useFakeTimers()
const q = new SerialQueue({ minIntervalMs: 1000 })
let t1 = 0
let t2 = 0
const p1 = q.run(async () => {
t1 = Date.now()
})
const p2 = q.run(async () => {
t2 = Date.now()
})
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
await Promise.all([p1, p2])
expect(t2 - t1).toBeGreaterThanOrEqual(1000)
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { openDatabase, type DB } from '../src/db.js'
import { resolveRipStatus } from '../src/ripstatus.js'
describe('resolveRipStatus', () => {
let db: DB
let itemId: number
beforeEach(() => {
db = openDatabase(':memory:')
db.prepare("INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'sam', 'x', 1)").run()
db.prepare(
"INSERT INTO collection_items (id, user_id, discogs_release_id, title, artist) VALUES (10, 1, 100, 'Motion', 'The Cinematic Orchestra')"
).run()
itemId = 10
})
function addAlbum(id: number, title: string, artist: string) {
db.prepare('INSERT INTO digital_albums (id, user_id, subsonic_id, title, artist) VALUES (?, 1, ?, ?, ?)').run(
id,
`sub-${id}`,
title,
artist
)
}
it('no album and no override → not_ripped', () => {
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
})
it('confident auto-match → ripped', () => {
addAlbum(1, 'Motion!', 'Cinematic Orchestra')
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
it('same title different artist → not_ripped', () => {
addAlbum(1, 'Motion', 'Massive Attack')
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
})
it('match link → ripped even without fuzzy match', () => {
addAlbum(1, 'Motion (Remastered)', 'The Cinematic Orchestra')
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (1, 10, 1)').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
it('manual override wins over everything (both directions)', () => {
addAlbum(1, 'Motion', 'The Cinematic Orchestra')
db.prepare('UPDATE collection_items SET rip_override = 0 WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('not_ripped')
db.prepare('UPDATE collection_items SET rip_override = 1 WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
db.prepare('UPDATE collection_items SET rip_override = NULL WHERE id = 10').run()
expect(resolveRipStatus(db, 1, itemId)).toBe('ripped')
})
})

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
function subsonicStubFetch(ok: boolean): typeof fetch {
return (async (url: any) => {
if (String(url).includes('/rest/ping')) {
const body = ok
? { 'subsonic-response': { status: 'ok' } }
: {
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password.' },
},
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('not found', { status: 404 })
}) as typeof fetch
}
describe('settings routes', () => {
it('starts empty and stores/clears tokens', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const empty = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(empty.json()).toEqual({
hasDiscogsToken: false,
discogsTokenMasked: null,
subsonicUrl: null,
subsonicUsername: null,
hasSubsonicPassword: false,
})
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'abcdef0123456789' },
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ hasDiscogsToken: true, discogsTokenMasked: '****56789' })
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: '' },
})
const cleared = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(cleared.json().hasDiscogsToken).toBe(false)
await app.close()
})
it('stores valid subsonic config after successful ping', async () => {
const app = await buildTestApp(subsonicStubFetch(true))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({
subsonicUrl: 'http://navidrome.local',
hasSubsonicPassword: true,
})
await app.close()
})
it('rejects bad subsonic credentials with 400 subsonic_auth', async () => {
const app = await buildTestApp(subsonicStubFetch(false))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'wrong',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_auth' })
await app.close()
})
it('rejects unreachable subsonic with 400 subsonic_unreachable', async () => {
const failing = (async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const app = await buildTestApp(failing)
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://nope.invalid',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_unreachable' })
await app.close()
})
it('requires auth', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/settings' })
expect(res.statusCode).toBe(401)
await app.close()
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { testConfig } from './helpers.js'
import { openDatabase } from '../src/db.js'
import { buildApp } from '../src/app.js'
describe('static serving', () => {
let dir: string
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), 'rs-static-'))
mkdirSync(path.join(dir, 'web', 'dist'), { recursive: true })
mkdirSync(path.join(dir, 'artwork'), { recursive: true })
writeFileSync(path.join(dir, 'web', 'dist', 'index.html'), '<html>record-shop</html>')
writeFileSync(path.join(dir, 'artwork', 'abc.jpg'), 'fakejpeg')
})
afterAll(() => {
rmSync(dir, { recursive: true, force: true })
})
async function build() {
const config = { ...testConfig(), artworkDir: path.join(dir, 'artwork') }
const app = await buildApp({
db: openDatabase(':memory:'),
config,
webDist: path.join(dir, 'web', 'dist'),
})
return app
}
it('serves index.html at / and SPA-falls back for client routes', async () => {
const app = await build()
const root = await app.inject({ method: 'GET', url: '/' })
expect(root.statusCode).toBe(200)
expect(root.body).toContain('record-shop')
const spa = await app.inject({ method: 'GET', url: '/library' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('record-shop')
await app.close()
})
it('serves artwork files', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/artwork/abc.jpg' })
expect(res.statusCode).toBe(200)
expect(res.body).toBe('fakejpeg')
await app.close()
})
it('unknown artwork paths return json 404, not the SPA', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/artwork/missing.jpg' })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
it('unknown api routes return json 404, not the SPA', async () => {
const app = await build()
const res = await app.inject({ method: 'GET', url: '/api/nope' })
expect(res.statusCode).toBe(404)
expect(res.json()).toEqual({ error: 'not_found' })
await app.close()
})
})

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest'
import { SubsonicClient, SubsonicError } from '../src/subsonic.js'
function subsonicResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function albumPage(count: number, offset: number) {
return {
'subsonic-response': {
status: 'ok',
albumList2: {
album: Array.from({ length: count }, (_, i) => ({
id: offset + i + 1,
name: `Album ${offset + i + 1}`,
artist: `Artist ${Math.floor((offset + i) / 10)}`,
})),
},
},
}
}
describe('SubsonicClient', () => {
it('ping sends auth params and succeeds', async () => {
const seen: string[] = []
const c = new SubsonicClient({
url: 'http://navidrome.local',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
seen.push(String(input))
return subsonicResponse({ 'subsonic-response': { status: 'ok' } })
}) as typeof fetch,
})
await c.ping()
expect(seen[0]!).toContain('/rest/ping')
expect(seen[0]!).toContain('u=sam')
expect(seen[0]!).toContain('v=1.16.1')
expect(seen[0]!).toContain('c=record-shop')
expect(seen[0]!).toContain('f=json')
expect(seen[0]!).toMatch(/t=[0-9a-f]{32}/)
})
it('ping raises auth error on failed status with code 40', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'bad',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password.' },
},
})) as typeof fetch,
})
const err = await c.ping().catch((e) => e)
expect(err).toBeInstanceOf(SubsonicError)
expect((err as SubsonicError).code).toBe('auth')
})
it('getAllAlbums paginates until a short page', async () => {
const seen: string[] = []
const c = new SubsonicClient({
url: 'http://navidrome.local',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
const url = new URL(String(input))
seen.push(url.searchParams.get('offset') ?? '')
const offset = Number(url.searchParams.get('offset') ?? 0)
// first page: 500 albums, second: 3, third never requested
return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500))
}) as typeof fetch,
})
const albums = await c.getAllAlbums()
expect(seen).toEqual(['0', '500'])
expect(albums).toHaveLength(503)
expect(albums[0]!).toEqual({ id: '1', title: 'Album 1', artist: 'Artist 0' })
})
it('getAllAlbums reports progress', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async (input: any) => {
const offset = Number(new URL(String(input)).searchParams.get('offset') ?? 0)
return subsonicResponse(offset === 0 ? albumPage(500, 0) : albumPage(3, 500))
}) as typeof fetch,
})
const progress: number[] = []
await c.getAllAlbums((_albums, done) => progress.push(done))
expect(progress[progress.length - 1]!).toBe(503)
})
it('getAllAlbums skips null album entries', async () => {
const c = new SubsonicClient({
url: 'http://x',
username: 'sam',
password: 'pass',
fetchImpl: (async () =>
subsonicResponse({
'subsonic-response': {
status: 'ok',
albumList2: {
album: [{ id: 1, name: 'Album 1', artist: 'Artist 0' }, null, { id: 2, name: 'Album 2', artist: 'Artist 0' }],
},
},
})) as typeof fetch,
})
const albums = await c.getAllAlbums()
expect(albums).toEqual([
{ id: '1', title: 'Album 1', artist: 'Artist 0' },
{ id: '2', title: 'Album 2', artist: 'Artist 0' },
])
})
})

View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*"]
}

5
server/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*", "test/**/*"]
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
}
}

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['server/test/**/*.test.ts'],
},
})