@@ -10,12 +10,22 @@ const copyBtn = document.getElementById('copy-btn')
1010const copyDialog = document . getElementById ( 'copy-dialog' )
1111const copyDialogHint = document . getElementById ( 'copy-dialog-hint' )
1212const copyDialogText = document . getElementById ( 'copy-dialog-text' )
13+ const spotifyMention = document . getElementById ( 'spotifyMention' )
1314
1415const URL_PARAM = 'url'
1516const canNativeShare = typeof navigator . share === 'function'
1617const shareBtnLabel = canNativeShare ? 'Share' : 'Copy link'
1718shareBtn . 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+
1929let spotifyToken = null
2030let tokenExpiry = 0
2131let 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 ( / ^ s p o t i f y : ( a l b u m | t r a c k ) : ( [ A - Z a - z 0 - 9 ] + ) $ / )
192- if ( uri ) return { type : uri [ 1 ] , id : uri [ 2 ] }
212+
213+ const spotifyUri = value . match ( / ^ s p o t i f y : ( a l b u m | t r a c k ) : ( [ A - Z a - z 0 - 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
209250async 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+ / ^ h t t p s ? : / 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+
316475function 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