diff --git a/server/src/discogs.ts b/server/src/discogs.ts new file mode 100644 index 0000000..de845c9 --- /dev/null +++ b/server/src/discogs.ts @@ -0,0 +1,149 @@ +import type { SerialQueue } from './queue.js' + +export interface DiscogsReleaseSummary { + id: number + artist: string + title: string + year: number | null + formats: string[] + labels: string[] + country: string | null + catno: string | null + thumbUrl: string | null +} + +export interface DiscogsReleaseFull extends DiscogsReleaseSummary { + genres: string[] + tracklist: { position: string; title: string }[] + coverUrl: string | null + barcodes: string[] +} + +export class DiscogsError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + } +} +export class DiscogsAuthError extends DiscogsError { + constructor() { + super(401, 'discogs token rejected') + } +} +export class DiscogsRateLimitError extends DiscogsError { + constructor() { + super(429, 'discogs rate limit hit') + } +} + +const USER_AGENT = 'record-shop/0.1.0' + +function splitTitle(title: string): { artist: string; title: string } { + const idx = title.indexOf(' - ') + if (idx === -1) return { artist: '', title } + return { artist: title.slice(0, idx), title: title.slice(idx + 3) } +} + +export function toYear(year: unknown): number | null { + const n = Number(year) + return Number.isInteger(n) && n > 0 ? n : null +} + +export function mapSearchResult(r: any): DiscogsReleaseSummary { + const { artist, title } = splitTitle(String(r.title ?? '')) + return { + id: Number(r.id), + artist, + title, + year: toYear(r.year), + formats: Array.isArray(r.format) ? r.format.map(String) : [], + labels: Array.isArray(r.label) ? r.label.map(String) : [], + country: r.country ?? null, + catno: r.catno ?? null, + thumbUrl: r.thumb ?? r.cover_image ?? null, + } +} + +export function mapRelease(r: any): DiscogsReleaseFull { + return { + id: Number(r.id), + artist: Array.isArray(r.artists) && r.artists[0] ? String(r.artists[0].name) : '', + title: String(r.title ?? ''), + year: toYear(r.year), + formats: Array.isArray(r.formats) ? r.formats.map((f: any) => String(f.name)) : [], + labels: Array.isArray(r.labels) ? r.labels.map((l: any) => String(l.name)) : [], + country: r.country ?? null, + catno: Array.isArray(r.labels) && r.labels[0] ? (r.labels[0].catno ?? null) : null, + thumbUrl: r.thumb ?? (r.images?.[0]?.uri ?? null), + genres: Array.isArray(r.genres) ? r.genres.map(String) : [], + tracklist: Array.isArray(r.tracklist) + ? r.tracklist + .filter((t: any) => t.title) + .map((t: any) => ({ position: String(t.position ?? ''), title: String(t.title) })) + : [], + coverUrl: r.images?.[0]?.uri ?? null, + barcodes: Array.isArray(r.identifiers) + ? r.identifiers + .filter((i: any) => i.type === 'Barcode' && i.value) + .map((i: any) => String(i.value)) + : [], + } +} + +export class DiscogsClient { + private token: string + private fetchImpl: typeof fetch + private baseUrl: string + private queue?: SerialQueue + + constructor(token: string, fetchImpl: typeof fetch = fetch, baseUrl = 'https://api.discogs.com', queue?: SerialQueue) { + this.token = token + this.fetchImpl = fetchImpl + this.baseUrl = baseUrl + this.queue = queue + } + + private async get(path: string, params: Record = {}): Promise { + const doFetch = async (): Promise => { + const url = new URL(`${this.baseUrl}${path}`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + url.searchParams.set('token', this.token) + let res: Response + try { + res = await this.fetchImpl(url.toString(), { + headers: { + Authorization: `Discogs token=${this.token}`, + 'User-Agent': USER_AGENT, + Accept: 'application/json', + }, + }) + } catch { + throw new DiscogsError(0, 'could not reach api.discogs.com') + } + if (res.status === 401) throw new DiscogsAuthError() + if (res.status === 429) throw new DiscogsRateLimitError() + if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`) + return res.json() + } + return this.queue ? this.queue.run(doFetch) : doFetch() + } + + async searchByBarcode(barcode: string): Promise { + const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' }) + return (body.results ?? []).map(mapSearchResult) + } + + async searchByText(query: string, format?: string): Promise { + const params: Record = { q: query, type: 'release', per_page: '20' } + if (format) params.format = format + const body = await this.get('/database/search', params) + return (body.results ?? []).map(mapSearchResult) + } + + async getRelease(id: number): Promise { + const body = await this.get(`/releases/${id}`) + return mapRelease(body) + } +} diff --git a/server/test/discogs.test.ts b/server/test/discogs.test.ts new file mode 100644 index 0000000..90f64cf --- /dev/null +++ b/server/test/discogs.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { + DiscogsClient, + DiscogsAuthError, + DiscogsRateLimitError, + mapSearchResult, + mapRelease, +} from '../src/discogs.js' +import { discogsSearchFixture, discogsReleaseFixture } from './fixtures.js' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function stubFetch(routes: (url: string) => Response): typeof fetch { + return (async (input: any) => routes(String(input))) as typeof fetch +} + +describe('DiscogsClient', () => { + it('searches by barcode with token and maps results', async () => { + const seen: string[] = [] + const c = new DiscogsClient( + 'testtoken', + stubFetch((url) => { + seen.push(url) + return jsonResponse(discogsSearchFixture) + }) + ) + const results = await c.searchByBarcode('5021592210629') + expect(seen[0]).toContain('/database/search') + expect(seen[0]).toContain('barcode=5021592210629') + expect(seen[0]).toContain('type=release') + expect(seen[0]).toContain('token=testtoken') + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + formats: ['CD', 'Album'], + labels: ['Ninja Tune'], + country: 'UK', + catno: 'ZENCD012', + thumbUrl: 'https://img.discogs.com/small1.jpg', + }) + }) + + it('searches by text with optional format filter', async () => { + const seen: string[] = [] + const c = new DiscogsClient( + 't', + stubFetch((url) => { + seen.push(url) + return jsonResponse(discogsSearchFixture) + }) + ) + await c.searchByText('motion', 'Vinyl') + expect(seen[0]).toContain('q=motion') + expect(seen[0]).toContain('format=Vinyl') + }) + + it('throws DiscogsAuthError on 401', async () => { + const c = new DiscogsClient('bad', stubFetch(() => jsonResponse({ message: 'bad' }, 401))) + await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsAuthError) + }) + + it('throws DiscogsRateLimitError on 429', async () => { + const c = new DiscogsClient('t', stubFetch(() => jsonResponse({}, 429))) + await expect(c.searchByBarcode('123')).rejects.toBeInstanceOf(DiscogsRateLimitError) + }) + + it('fetches full release with barcode identifiers', async () => { + const c = new DiscogsClient( + 't', + stubFetch((url) => { + expect(url).toContain('/releases/1001') + return jsonResponse(discogsReleaseFixture) + }) + ) + const release = await c.getRelease(1001) + expect(release.title).toBe('Motion') + expect(release.barcodes).toEqual(['5021592210629']) + expect(release.coverUrl).toBe('https://img.discogs.com/full1.jpg') + }) +}) + +describe('mappers', () => { + it('splits search title into artist/title and parses year', () => { + const 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' }, + ]) + }) +}) diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts new file mode 100644 index 0000000..bee498d --- /dev/null +++ b/server/test/fixtures.ts @@ -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' }], +}