Compare commits
32 Commits
25315247bc
...
b55a1f8cc1
| Author | SHA1 | Date | |
|---|---|---|---|
| b55a1f8cc1 | |||
| 56ffda9e88 | |||
| 39c956e67d | |||
| ba705d546a | |||
| 061f7349b5 | |||
| a3bf420282 | |||
| 875bc1087c | |||
| f1d6320dfe | |||
| 566a9bfc0d | |||
| ded4e17849 | |||
| e8a83eeefb | |||
| c49321ccf1 | |||
| 217c676658 | |||
| c1dd232a2f | |||
| 0faa74ae27 | |||
| 9b52039fde | |||
| dc63db54ba | |||
| 72f3fc8436 | |||
| 3715bb7447 | |||
| b79a72a81b | |||
| a54f56834d | |||
| e94ffb535b | |||
| 6dfaaf465d | |||
| 8bdbe292d5 | |||
| 1c52281feb | |||
| ad8b7cb22a | |||
| f48093319b | |||
| dff7b4826d | |||
| 5545aa31fb | |||
| 01ad6fbb40 | |||
| 52b2cbd6c5 | |||
| 1dfe47f5dd |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
server/dist
|
||||
web/dist
|
||||
data
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
server/dist/
|
||||
web/dist/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.env*
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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"]
|
||||
28
README.md
28
README.md
@@ -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`
|
||||
|
||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
record-shop:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
4104
docs/superpowers/plans/2026-08-29-record-shop-backend.md
Normal file
4104
docs/superpowers/plans/2026-08-29-record-shop-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal file
138
docs/superpowers/specs/2026-08-29-record-shop-design.md
Normal 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 (3–4 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
2870
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
83
server/src/app.ts
Normal 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
45
server/src/artwork.ts
Normal 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
66
server/src/auth.ts
Normal 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
38
server/src/config.ts
Normal 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
80
server/src/db.ts
Normal 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
157
server/src/discogs.ts
Normal 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
18
server/src/index.ts
Normal 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
43
server/src/matcher.ts
Normal 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
27
server/src/queue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
31
server/src/releaseCache.ts
Normal file
31
server/src/releaseCache.ts
Normal 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
64
server/src/ripstatus.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
168
server/src/routes/authRoutes.ts
Normal file
168
server/src/routes/authRoutes.ts
Normal 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 })
|
||||
}
|
||||
)
|
||||
}
|
||||
217
server/src/routes/collectionRoutes.ts
Normal file
217
server/src/routes/collectionRoutes.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
80
server/src/routes/libraryRoutes.ts
Normal file
80
server/src/routes/libraryRoutes.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
87
server/src/routes/lookupRoutes.ts
Normal file
87
server/src/routes/lookupRoutes.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
107
server/src/routes/settingsRoutes.ts
Normal file
107
server/src/routes/settingsRoutes.ts
Normal 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
99
server/src/subsonic.ts
Normal 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
72
server/src/sync.ts
Normal 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
17
server/test/app.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
94
server/test/artwork.test.ts
Normal file
94
server/test/artwork.test.ts
Normal 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
50
server/test/auth.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
179
server/test/authRoutes.test.ts
Normal file
179
server/test/authRoutes.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
215
server/test/collection.test.ts
Normal file
215
server/test/collection.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
46
server/test/config.test.ts
Normal file
46
server/test/config.test.ts
Normal 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
37
server/test/db.test.ts
Normal 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
119
server/test/discogs.test.ts
Normal 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
45
server/test/fixtures.ts
Normal 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
71
server/test/helpers.ts
Normal 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
206
server/test/library.test.ts
Normal 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
193
server/test/lookup.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
60
server/test/matcher.test.ts
Normal file
60
server/test/matcher.test.ts
Normal 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
42
server/test/queue.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
58
server/test/ripstatus.test.ts
Normal file
58
server/test/ripstatus.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
124
server/test/settings.test.ts
Normal file
124
server/test/settings.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
68
server/test/static.test.ts
Normal file
68
server/test/static.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
121
server/test/subsonic.test.ts
Normal file
121
server/test/subsonic.test.ts
Normal 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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
5
server/tsconfig.build.json
Normal file
5
server/tsconfig.build.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
5
server/tsconfig.json
Normal file
5
server/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal 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
7
vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['server/test/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user