Skip to content

Commit 382f325

Browse files
committed
deezer support and optional spotify
1 parent 8f1370f commit 382f325

4 files changed

Lines changed: 210 additions & 50 deletions

File tree

.github/workflows/deploy-pages.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ jobs:
3636
3737
client_id = os.environ.get('CLIENT_ID') or ''
3838
client_secret = os.environ.get('CLIENT_SECRET') or ''
39-
if not client_id or not client_secret:
40-
raise SystemExit('Missing BARCODER_SPOTIFY_CLIENT_ID or BARCODER_SPOTIFY_SECRET_WEB in barcoder-prod')
4139
4240
Path('_site/config.js').write_text(
4341
'window.BARCODER = {\n'

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
# Barcoder
22

3-
Lookup **ISRC** and **UPC** barcodes for Spotify albums and tracks.
3+
Lookup **ISRC** and **UPC** barcodes for Spotify and Deezer albums and tracks. Note that Spotify quotas are inhumanly tiny and it's not per app, but per developr account. Deezer requires no API key and is quite generous with quotas.
44

55
**Live:** [https://barcoder.wrongwave.net](https://barcoder.wrongwave.net)
66

77
Paste an album or track URL to get release metadata, a tracklist with ISRCs, and a copyable text summary. Links are shareable via `?url=`.
88

9+
Spotify needs credentials in `config.js`. Deezer works without a key (JSONP).
10+
911
## Local setup
1012

11-
1. Create a [Spotify Developer](https://developer.spotify.com/dashboard) app and copy the Client ID and Client Secret.
13+
1. [Optional] Create a [Spotify Developer](https://developer.spotify.com/dashboard) app and copy the Client ID and Client Secret — needed only if you want to support Spotify links.
1214
2. Copy the config example and fill in your credentials:
1315

1416
```bash

app.js

Lines changed: 199 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,22 @@ const copyBtn = document.getElementById('copy-btn')
1010
const copyDialog = document.getElementById('copy-dialog')
1111
const copyDialogHint = document.getElementById('copy-dialog-hint')
1212
const copyDialogText = document.getElementById('copy-dialog-text')
13+
const spotifyMention = document.getElementById('spotifyMention')
1314

1415
const URL_PARAM = 'url'
1516
const canNativeShare = typeof navigator.share === 'function'
1617
const shareBtnLabel = canNativeShare ? 'Share' : 'Copy link'
1718
shareBtn.textContent = shareBtnLabel
1819

20+
const spotifyCreds = credentials()
21+
const hasSpotifyCreds = Boolean(spotifyCreds.clientId && spotifyCreds.clientSecret)
22+
if (spotifyMention && !hasSpotifyCreds) {
23+
spotifyMention.style.display = 'none'
24+
}
25+
if (!hasSpotifyCreds) {
26+
input.placeholder = 'Deezer album/track URL'
27+
}
28+
1929
let spotifyToken = null
2030
let tokenExpiry = 0
2131
let lastPayload = null
@@ -161,20 +171,14 @@ async function lookup(url) {
161171
copyBtn.disabled = true
162172

163173
try {
164-
const { clientId, clientSecret } = credentials()
165-
if (!clientId || !clientSecret) {
166-
throw new Error('Add Spotify clientId and clientSecret to config.js')
167-
}
168-
169-
const parsed = parseSpotifyUrl(url)
174+
const parsed = parseMusicUrl(url)
170175
if (!parsed) {
171-
throw new Error('Paste a Spotify album or track URL')
176+
throw new Error('Paste a Spotify or Deezer album/track URL')
172177
}
173178

174-
const token = await getToken(clientId, clientSecret)
175-
const payload = parsed.type === 'album'
176-
? await fetchAlbumPayload(token, parsed.id)
177-
: await fetchTrackPayload(token, parsed.id)
179+
const payload = parsed.provider === 'deezer'
180+
? await lookupDeezer(parsed)
181+
: await lookupSpotify(parsed)
178182

179183
lastPayload = payload
180184
render(payload)
@@ -186,24 +190,61 @@ async function lookup(url) {
186190
}
187191
}
188192

189-
function parseSpotifyUrl(raw) {
193+
async function lookupSpotify(parsed) {
194+
const { clientId, clientSecret } = credentials()
195+
if (!clientId || !clientSecret) {
196+
throw new Error('Add Spotify clientId and clientSecret to config.js')
197+
}
198+
const token = await getToken(clientId, clientSecret)
199+
return parsed.type === 'album'
200+
? await fetchSpotifyAlbumPayload(token, parsed.id)
201+
: await fetchSpotifyTrackPayload(token, parsed.id)
202+
}
203+
204+
async function lookupDeezer(parsed) {
205+
return parsed.type === 'album'
206+
? await fetchDeezerAlbumPayload(parsed.id)
207+
: await fetchDeezerTrackPayload(parsed.id)
208+
}
209+
210+
function parseMusicUrl(raw) {
190211
const value = raw.trim()
191-
const uri = value.match(/^spotify:(album|track):([A-Za-z0-9]+)$/)
192-
if (uri) return { type: uri[1], id: uri[2] }
212+
213+
const spotifyUri = value.match(/^spotify:(album|track):([A-Za-z0-9]+)$/i)
214+
if (spotifyUri) {
215+
return { provider: 'spotify', type: spotifyUri[1].toLowerCase(), id: spotifyUri[2] }
216+
}
193217

194218
try {
195219
const u = new URL(value)
196-
if (!u.hostname.includes('spotify.com')) return null
197-
const parts = u.pathname.split('/').filter(Boolean)
198-
const typeIndex = parts.findIndex((p) => p === 'album' || p === 'track')
199-
if (typeIndex === -1 || !parts[typeIndex + 1]) return null
200-
return {
201-
type: parts[typeIndex],
202-
id: parts[typeIndex + 1].split('?')[0],
220+
const host = (u.hostname || '').toLowerCase()
221+
222+
if (host.includes('spotify.com')) {
223+
const parts = u.pathname.split('/').filter(Boolean)
224+
const typeIndex = parts.findIndex((p) => p === 'album' || p === 'track')
225+
if (typeIndex === -1 || !parts[typeIndex + 1]) return null
226+
return {
227+
provider: 'spotify',
228+
type: parts[typeIndex],
229+
id: parts[typeIndex + 1].split('?')[0],
230+
}
231+
}
232+
233+
if (host.includes('deezer.com')) {
234+
const parts = u.pathname.split('/').filter(Boolean)
235+
const typeIndex = parts.findIndex((p) => p === 'album' || p === 'track')
236+
if (typeIndex === -1 || !parts[typeIndex + 1]) return null
237+
return {
238+
provider: 'deezer',
239+
type: parts[typeIndex],
240+
id: parts[typeIndex + 1].split('?')[0],
241+
}
203242
}
204243
} catch {
205244
return null
206245
}
246+
247+
return null
207248
}
208249

209250
async function getToken(clientId, clientSecret) {
@@ -238,20 +279,20 @@ async function spotifyGet(token, path) {
238279
return response.json()
239280
}
240281

241-
async function fetchAlbumPayload(token, albumId) {
282+
async function fetchSpotifyAlbumPayload(token, albumId) {
242283
const album = await spotifyGet(token, `/albums/${albumId}`)
243-
const trackIds = await collectAlbumTrackIds(token, album)
244-
const tracks = await fetchTracksByIds(token, trackIds)
245-
return buildPayload(album, tracks)
284+
const trackIds = await collectSpotifyAlbumTrackIds(token, album)
285+
const tracks = await fetchSpotifyTracksByIds(token, trackIds)
286+
return buildSpotifyPayload(album, tracks)
246287
}
247288

248-
async function fetchTrackPayload(token, trackId) {
289+
async function fetchSpotifyTrackPayload(token, trackId) {
249290
const track = await spotifyGet(token, `/tracks/${trackId}`)
250291
const album = await spotifyGet(token, `/albums/${track.album.id}`)
251-
return buildPayload(album, [track], { focusTrackId: track.id })
292+
return buildSpotifyPayload(album, [track], { focusTrackId: track.id })
252293
}
253294

254-
async function collectAlbumTrackIds(token, album) {
295+
async function collectSpotifyAlbumTrackIds(token, album) {
255296
const ids = []
256297
let next = album.tracks?.next || null
257298

@@ -274,7 +315,7 @@ async function collectAlbumTrackIds(token, album) {
274315
return ids
275316
}
276317

277-
async function fetchTracksByIds(token, ids) {
318+
async function fetchSpotifyTracksByIds(token, ids) {
278319
// Dev Mode (Feb 2026+): batch GET /tracks?ids=… returns 403. Fetch one-by-one.
279320
const tracks = []
280321
const concurrency = 6
@@ -288,7 +329,7 @@ async function fetchTracksByIds(token, ids) {
288329
return tracks
289330
}
290331

291-
function buildPayload(album, tracks, options = {}) {
332+
function buildSpotifyPayload(album, tracks, options = {}) {
292333
const rows = tracks.map((track) => ({
293334
number: track.track_number,
294335
disc: track.disc_number,
@@ -297,24 +338,143 @@ function buildPayload(album, tracks, options = {}) {
297338
isrc: track.external_ids?.isrc || '',
298339
id: track.id,
299340
durationMs: track.duration_ms || 0,
300-
spotifyUrl: track.external_urls?.spotify || `https://open.spotify.com/track/${track.id}`,
341+
url: track.external_urls?.spotify || `https://open.spotify.com/track/${track.id}`,
301342
}))
302343

303344
return {
345+
provider: 'spotify',
304346
album: album.name,
305347
artists: (album.artists || []).map((a) => a.name).join(', '),
306348
upc: album.external_ids?.upc || '',
307349
label: album.label || '',
308350
releaseDate: album.release_date || '',
309-
spotifyUrl: album.external_urls?.spotify || '',
351+
sourceUrl: album.external_urls?.spotify || '',
352+
durationMs: rows.reduce((sum, track) => sum + track.durationMs, 0),
353+
tracks: rows,
354+
focusTrackId: options.focusTrackId || null,
355+
}
356+
}
357+
358+
function deezerJsonp(pathOrUrl) {
359+
return new Promise((resolve, reject) => {
360+
const callback = `dzcb_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
361+
const script = document.createElement('script')
362+
let settled = false
363+
364+
const cleanup = () => {
365+
clearTimeout(timer)
366+
try { delete window[callback] } catch {}
367+
script.remove()
368+
}
369+
370+
const finish = (fn, value) => {
371+
if (settled) return
372+
settled = true
373+
cleanup()
374+
fn(value)
375+
}
376+
377+
const timer = setTimeout(() => {
378+
finish(reject, new Error('Deezer request timed out'))
379+
}, 15000)
380+
381+
window[callback] = (data) => {
382+
if (data?.error) {
383+
finish(reject, new Error(data.error.message || 'Deezer API error'))
384+
return
385+
}
386+
finish(resolve, data)
387+
}
388+
389+
script.onerror = () => finish(reject, new Error('Deezer request failed'))
390+
391+
const url = new URL(
392+
/^https?:/i.test(pathOrUrl)
393+
? pathOrUrl
394+
: `https://api.deezer.com${pathOrUrl}`
395+
)
396+
url.searchParams.set('output', 'jsonp')
397+
url.searchParams.set('callback', callback)
398+
script.src = url.toString()
399+
document.head.appendChild(script)
400+
})
401+
}
402+
403+
async function fetchDeezerAlbumPayload(albumId) {
404+
const album = await deezerJsonp(`/album/${albumId}`)
405+
if (!album?.id) throw new Error('Deezer album not found')
406+
const tracks = await collectDeezerAlbumTracks(albumId)
407+
return buildDeezerPayload(album, tracks)
408+
}
409+
410+
async function fetchDeezerTrackPayload(trackId) {
411+
const track = await deezerJsonp(`/track/${trackId}`)
412+
if (!track?.id) throw new Error('Deezer track not found')
413+
const albumId = track.album?.id
414+
if (!albumId) throw new Error('Deezer track has no album')
415+
const album = await deezerJsonp(`/album/${albumId}`)
416+
return buildDeezerPayload(album, [track], { focusTrackId: String(track.id) })
417+
}
418+
419+
async function collectDeezerAlbumTracks(albumId) {
420+
const tracks = []
421+
let next = `/album/${albumId}/tracks?limit=100`
422+
423+
while (next) {
424+
const page = await deezerJsonp(next)
425+
for (const item of page.data || []) {
426+
if (item) tracks.push(item)
427+
}
428+
next = page.next || null
429+
}
430+
431+
return tracks
432+
}
433+
434+
function buildDeezerPayload(album, tracks, options = {}) {
435+
const albumArtists = deezerArtistNames(album)
436+
const rows = tracks.map((track) => ({
437+
number: track.track_position || 0,
438+
disc: track.disk_number || 1,
439+
title: track.title || '',
440+
artists: deezerTrackArtistNames(track, album, albumArtists),
441+
isrc: track.isrc || '',
442+
id: String(track.id),
443+
durationMs: (track.duration || 0) * 1000,
444+
url: track.link || `https://www.deezer.com/track/${track.id}`,
445+
}))
446+
447+
return {
448+
provider: 'deezer',
449+
album: album.title || '',
450+
artists: albumArtists,
451+
upc: album.upc || '',
452+
label: album.label || '',
453+
releaseDate: album.release_date || '',
454+
sourceUrl: album.link || `https://www.deezer.com/album/${album.id}`,
310455
durationMs: rows.reduce((sum, track) => sum + track.durationMs, 0),
311456
tracks: rows,
312457
focusTrackId: options.focusTrackId || null,
313458
}
314459
}
315460

461+
function deezerArtistNames(entity) {
462+
if (entity?.contributors?.length) {
463+
return entity.contributors.map((a) => a.name).filter(Boolean).join(', ')
464+
}
465+
return entity?.artist?.name || ''
466+
}
467+
468+
function deezerTrackArtistNames(track, album, albumArtists) {
469+
if (track?.contributors?.length) return deezerArtistNames(track)
470+
// Album track lists omit contributors; reuse album artists when it's a multi-artist release.
471+
if (album?.contributors?.length > 1) return albumArtists
472+
return deezerArtistNames(track) || albumArtists
473+
}
474+
316475
function render(payload) {
317476
const upc = payload.upc || '—'
477+
const providerLabel = payload.provider === 'deezer' ? 'deezer' : 'spotify'
318478
metaEl.innerHTML = `
319479
<div><em>‘${escapeHtml(payload.album)}’</em> by ${escapeHtml(payload.artists)}</div>
320480
<div>UPC: <strong>${escapeHtml(upc)}</strong></div>
@@ -323,8 +483,8 @@ function render(payload) {
323483
${payload.releaseDate ? `<div>Released on ${escapeHtml(payload.releaseDate)}</div>` : ''}
324484
`
325485

326-
if (payload.focusTrackId && payload.spotifyUrl) {
327-
albumBtn.href = barcoderHref(payload.spotifyUrl)
486+
if (payload.focusTrackId && payload.sourceUrl) {
487+
albumBtn.href = barcoderHref(payload.sourceUrl)
328488
albumBtn.hidden = false
329489
} else {
330490
albumBtn.hidden = true
@@ -334,10 +494,10 @@ function render(payload) {
334494
const multiDisc = payload.tracks.some((t) => t.disc > 1)
335495
tracksBody.innerHTML = payload.tracks.map((track) => {
336496
const num = multiDisc ? `${track.disc}.${track.number}` : String(track.number)
337-
const highlight = payload.focusTrackId && track.id === payload.focusTrackId
497+
const highlight = payload.focusTrackId && String(track.id) === String(payload.focusTrackId)
338498
? ' data-focus="true"'
339499
: ''
340-
const infoHref = barcoderHref(track.spotifyUrl)
500+
const infoHref = barcoderHref(track.url)
341501
return `
342502
<tr${highlight}>
343503
<td>${escapeHtml(num)}</td>
@@ -346,7 +506,7 @@ function render(payload) {
346506
<td class=isrc>${escapeHtml(track.isrc || '—')}</td>
347507
<td class=links>
348508
<a href="${escapeHtml(infoHref)}">info</a>
349-
<a href="${escapeHtml(track.spotifyUrl)}" target=_blank rel=noopener>spotify</a>
509+
<a href="${escapeHtml(track.url)}" target=_blank rel=noopener>${providerLabel}</a>
350510
</td>
351511
</tr>
352512
`
@@ -355,9 +515,9 @@ function render(payload) {
355515
resultsEl.hidden = false
356516
}
357517

358-
function barcoderHref(spotifyUrl) {
518+
function barcoderHref(musicUrl) {
359519
const next = new URL(location.href)
360-
next.searchParams.set(URL_PARAM, spotifyUrl)
520+
next.searchParams.set(URL_PARAM, musicUrl)
361521
next.hash = ''
362522
return next.pathname + next.search
363523
}

0 commit comments

Comments
 (0)