From ded4e17849acc3ac7f2ad5009a7ea1de2cf6ed19 Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 17:07:04 +0200 Subject: [PATCH] feat: discogs lookup routes (barcode/search/release preview) Known-red tests pending later tasks: - 'reports duplicate when release already in collection' needs POST /api/collection (Task 15) - 'reports ripped...' and 'reports ambiguous...' need the test-only POST /api/library/albums/test-seed route (Task 16) --- server/src/app.ts | 5 + server/src/routes/lookupRoutes.ts | 86 ++++++++++++++++ server/test/lookup.test.ts | 165 ++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 server/src/routes/lookupRoutes.ts create mode 100644 server/test/lookup.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index b0df77a..b4e444f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,14 +2,17 @@ import Fastify, { FastifyInstance } from 'fastify' import Database from 'better-sqlite3' import cookie from '@fastify/cookie' import type { Config } from './config.js' +import { SerialQueue } from './queue.js' import { registerAuthRoutes } from './routes/authRoutes.js' import { registerSettingsRoutes } from './routes/settingsRoutes.js' +import { registerLookupRoutes } from './routes/lookupRoutes.js' declare module 'fastify' { interface FastifyInstance { db: Database.Database config: Config fetchImpl: typeof fetch + discogsQueue: SerialQueue } interface FastifyRequest { user?: import('./auth.js').UserRow @@ -27,10 +30,12 @@ export async function buildApp(opts: AppOptions): Promise { app.decorate('db', opts.db) app.decorate('config', opts.config) app.decorate('fetchImpl', opts.fetchImpl ?? fetch) + app.decorate('discogsQueue', new SerialQueue({ minIntervalMs: 1100 })) await app.register(cookie) await registerAuthRoutes(app) await registerSettingsRoutes(app) + await registerLookupRoutes(app) app.get('/api/health', async () => ({ ok: true })) return app diff --git a/server/src/routes/lookupRoutes.ts b/server/src/routes/lookupRoutes.ts new file mode 100644 index 0000000..8654ed5 --- /dev/null +++ b/server/src/routes/lookupRoutes.ts @@ -0,0 +1,86 @@ +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 } { + 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 { + app.get('/api/lookup/barcode/:code', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + try { + const candidates = await client.searchByBarcode((request.params as { code: string }).code) + if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' }) + return { candidates } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) + + app.get('/api/lookup/search', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const { q, format } = request.query as { q?: string; format?: string } + if (!q) return reply.code(400).send({ error: 'missing_query' }) + try { + const candidates = await client.searchByText(q, format || undefined) + if (candidates.length === 0) return reply.code(404).send({ error: 'not_found' }) + return { candidates } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) + + app.get('/api/lookup/release/:id', { preHandler: [requireAuth] }, async (request, reply) => { + const client = discogsClientFor(request) + if (!client) return reply.code(409).send({ error: 'no_discogs_token' }) + const id = Number((request.params as { id: string }).id) + try { + const release = await client.getRelease(id) + const db = request.server.db + const userId = request.user!.id + const duplicate = !!db + .prepare('SELECT id FROM collection_items WHERE user_id = ? AND discogs_release_id = ?') + .get(userId, id) + const albums = db + .prepare('SELECT id, title, artist FROM digital_albums WHERE user_id = ?') + .all(userId) as { id: number; title: string; artist: string }[] + const confident = albums.some((a) => isConfidentMatch(release, a)) + const matchCandidates = confident ? [] : candidateAlbums(release, albums) + return { + release, + duplicate, + ripMatch: confident ? 'ripped' : matchCandidates.length > 0 ? 'ambiguous' : 'not_ripped', + matchCandidates, + } + } catch (err) { + const { code, body } = discogsErrorStatus(err) + return reply.code(code).send(body) + } + }) +} diff --git a/server/test/lookup.test.ts b/server/test/lookup.test.ts new file mode 100644 index 0000000..8bb9115 --- /dev/null +++ b/server/test/lookup.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest' +import { buildTestApp, setupAdmin, auth } from './helpers.js' +import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js' + +function stubFetch(routes: (url: string) => Response): typeof fetch { + return (async (input: any) => routes(String(input))) as typeof fetch +} + +function discogsStub(): typeof fetch { + return stubFetch((url) => { + if (url.includes('/database/search')) { + return new Response(JSON.stringify(discogsSearchFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.includes('/releases/1001')) { + return new Response(JSON.stringify(discogsReleaseFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('nope', { status: 404 }) + }) +} + +async function appWithToken(discogsFetch: typeof fetch) { + const app = await buildTestApp(discogsFetch) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { discogsToken: 'testtoken' }, + }) + return { app, cookie } +} + +describe('GET /api/lookup/barcode/:code', () => { + it('returns candidates on match', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/5021592210629', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.candidates).toHaveLength(2) + expect(body.candidates[0]).toMatchObject({ + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + }) + await app.close() + }) + + it('returns 404 not_found when discogs has zero results', async () => { + const empty = stubFetch((url) => + url.includes('/database/search') + ? new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + : new Response('nope', { status: 404 }) + ) + const { app, cookie } = await appWithToken(empty) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(404) + expect(res.json()).toEqual({ error: 'not_found' }) + await app.close() + }) + + it('returns 409 no_discogs_token when token unset', async () => { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(409) + expect(res.json()).toEqual({ error: 'no_discogs_token' }) + await app.close() + }) + + it('maps discogs auth failure to 502 discogs_auth', async () => { + const unauthorized = stubFetch(() => new Response('bad token', { status: 401 })) + const { app, cookie } = await appWithToken(unauthorized) + const res = await app.inject({ method: 'GET', url: '/api/lookup/barcode/000', ...auth(cookie) }) + expect(res.statusCode).toBe(502) + expect(res.json()).toEqual({ error: 'discogs_auth' }) + await app.close() + }) +}) + +describe('GET /api/lookup/search', () => { + it('returns candidates for text query with format filter', async () => { + const seen: string[] = [] + const { app, cookie } = await appWithToken( + stubFetch((url) => { + seen.push(url) + return new Response(JSON.stringify(discogsSearchFixture), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/lookup/search?q=motion&format=Vinyl', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + expect(seen[0]).toContain('q=motion') + expect(seen[0]).toContain('format=Vinyl') + expect(res.json().candidates).toHaveLength(2) + await app.close() + }) +}) + +describe('GET /api/lookup/release/:id', () => { + it('returns full release with duplicate and ripMatch', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.release).toMatchObject({ + id: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + barcodes: ['5021592210629'], + }) + expect(body.duplicate).toBe(false) + expect(body.ripMatch).toBe('not_ripped') + expect(body.matchCandidates).toEqual([]) + await app.close() + }) + + it('reports duplicate when release already in collection', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.json().duplicate).toBe(true) + await app.close() + }) + + it('reports ripped on confident match against digital albums', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'The Cinematic Orchestra' }] }, + }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + expect(res.json().ripMatch).toBe('ripped') + await app.close() + }) + + it('reports ambiguous with matchCandidates on same-title different-artist', async () => { + const { app, cookie } = await appWithToken(discogsStub()) + await app.inject({ + method: 'POST', + url: '/api/library/albums/test-seed', + ...auth(cookie), + payload: { albums: [{ subsonicId: 'a1', title: 'Motion', artist: 'Somebody Else' }] }, + }) + const res = await app.inject({ method: 'GET', url: '/api/lookup/release/1001', ...auth(cookie) }) + const body = res.json() + expect(body.ripMatch).toBe('ambiguous') + expect(body.matchCandidates).toHaveLength(1) + expect(body.matchCandidates[0]).toMatchObject({ title: 'Motion', artist: 'Somebody Else' }) + await app.close() + }) +})