feat: discogs client with barcode/text search and release fetch
This commit is contained in:
149
server/src/discogs.ts
Normal file
149
server/src/discogs.ts
Normal file
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
server/test/discogs.test.ts
Normal file
108
server/test/discogs.test.ts
Normal file
@@ -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' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
45
server/test/fixtures.ts
Normal file
45
server/test/fixtures.ts
Normal file
@@ -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' }],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user