1
0
Files
record-shop/server/src/discogs.ts

150 lines
4.7 KiB
TypeScript
Raw Normal View History

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<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)
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<DiscogsReleaseSummary[]> {
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<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)
return (body.results ?? []).map(mapSearchResult)
}
async getRelease(id: number): Promise<DiscogsReleaseFull> {
const body = await this.get(`/releases/${id}`)
return mapRelease(body)
}
}