2026-08-29 16:13:43 +02:00
|
|
|
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)
|
2026-08-29 16:24:40 +02:00
|
|
|
this.name = new.target.name
|
2026-08-29 16:13:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 {
|
2026-08-29 16:24:40 +02:00
|
|
|
const src = r ?? {}
|
|
|
|
|
const { artist, title } = splitTitle(String(src.title ?? ''))
|
2026-08-29 16:13:43 +02:00
|
|
|
return {
|
2026-08-29 16:24:40 +02:00
|
|
|
id: Number(src.id),
|
2026-08-29 16:13:43 +02:00
|
|
|
artist,
|
|
|
|
|
title,
|
2026-08-29 16:24:40 +02:00
|
|
|
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,
|
2026-08-29 16:13:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mapRelease(r: any): DiscogsReleaseFull {
|
2026-08-29 16:24:40 +02:00
|
|
|
const src = r ?? {}
|
2026-08-29 16:13:43 +02:00
|
|
|
return {
|
2026-08-29 16:24:40 +02:00
|
|
|
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 ?? '') }))
|
2026-08-29 16:13:43 +02:00
|
|
|
: [],
|
2026-08-29 16:24:40 +02:00
|
|
|
coverUrl: src.images?.[0]?.uri ?? null,
|
|
|
|
|
barcodes: Array.isArray(src.identifiers)
|
|
|
|
|
? src.identifiers
|
|
|
|
|
.filter((i: any) => i?.type === 'Barcode' && i?.value)
|
2026-08-29 16:13:43 +02:00
|
|
|
.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}`)
|
2026-08-29 16:24:40 +02:00
|
|
|
try {
|
|
|
|
|
return await res.json()
|
|
|
|
|
} catch {
|
|
|
|
|
throw new DiscogsError(res.status, 'discogs returned a non-JSON response')
|
|
|
|
|
}
|
2026-08-29 16:13:43 +02:00
|
|
|
}
|
|
|
|
|
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' })
|
2026-08-29 16:24:40 +02:00
|
|
|
const results: any[] = Array.isArray(body?.results) ? body.results : []
|
|
|
|
|
return results.map(mapSearchResult)
|
2026-08-29 16:13:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-29 16:24:40 +02:00
|
|
|
const results: any[] = Array.isArray(body?.results) ? body.results : []
|
|
|
|
|
return results.map(mapSearchResult)
|
2026-08-29 16:13:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getRelease(id: number): Promise<DiscogsReleaseFull> {
|
|
|
|
|
const body = await this.get(`/releases/${id}`)
|
|
|
|
|
return mapRelease(body)
|
|
|
|
|
}
|
|
|
|
|
}
|