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 = {}): 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) 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 { 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 { const params: Record = { 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 { const body = await this.get(`/releases/${id}`) return mapRelease(body) } }