import { Router } from 'itty-router' const ID3Writer = require('browser-id3-writer'); const router = Router() const client_id = "447462" const client_secret = "a83bf7f38ad2f137e444727cfc3775cf" const formats = { aac_96: { num: '8', gw: 'AAC_96', mime: 'audio/3gpp', ext: '3gp' }, 64: { num: '10', gw: 'MP3_64', mime: 'audio/mpeg', ext: 'mp3' }, 128: { num: '1', gw: 'MP3_128', mime: 'audio/mpeg', ext: 'mp3' }, 320: { num: '3', gw: 'MP3_320', mime: 'audio/mpeg', ext: 'mp3' }, flac: { num: '9', gw: 'FLAC', mime: 'audio/flac', ext: 'flac' }, mp4_ra1: { num: '13', gw: 'MP4_RA1', mime: 'audio/mp4', ext: 'mp4' }, mp4_ra2: { num: '14', gw: 'MP4_RA2', mime: 'audio/mp4', ext: 'mp4' }, mp4_ra3: { num: '15', gw: 'MP4_RA3', mime: 'audio/mp4', ext: 'mp4' }, mhm1_ra1: { num: '16', gw: 'MHM1_RA1', mime: 'audio/mp4', ext: 'mp4' }, mhm1_ra2: { num: '17', gw: 'MHM1_RA2', mime: 'audio/mp4', ext: 'mp4' }, mhm1_ra3: { num: '18', gw: 'MHM1_RA3', mime: 'audio/mp4', ext: 'mp4' }, sbc_256: { num: '12', gw: 'SBC_256', mime: '', ext: 'sbc' }, misc: { num: '0', gw: 'MP3_MISC', mime: 'audio/mpeg', ext: 'mp3' }, } async function gw_api_call(method, params) { if (method === 'deezer.getUserData') { checkForm = '' } if (!params) { params = {} } let cookies = `arl=${ARL}` if (sid) { cookies += `; sid=${sid}` } const headers = new Headers({ 'cookie': cookies }) const init = { method: 'POST', headers: headers, body: JSON.stringify(params), } const response = await fetch(`https://www.deezer.com/ajax/gw-light.php?method=${method}&input=3&api_version=1.0&api_token=${encodeURIComponent(checkForm)}&cid=${Math.floor(Math.random() * 1e9)}`, init) const json = await response.json() if (json.error.length !== 0) { return new Response(JSON.stringify(json.error), { status: 500, headers: { 'content-type': 'application/json' } }) } if (method === 'deezer.getUserData') { checkForm = json.results.checkForm await KV.put('checkForm', checkForm) sid = response.headers.get('set-cookie').split(',').map(v => v.trimStart())[0] sid = sid.match(/^sid=(fr[\da-f]+)/)[1] await KV.put('sid', sid) } return json.results } router.get('/:type/:id', async request => { const { query } = request let { type, id } = request.params if (!['track', 'album', 'playlist'].includes(type)) { return new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } }) } id = parseInt(id) if (id === NaN || (type !== 'track' && id < 0)) { return new Response("Invalid ID", { status: 400, headers: { 'content-type': 'text/plain' } }) } let format = query.f if (format === undefined) { format = '320' } else { format = format.toLowerCase() if (formats[format] === undefined) { let nums = [] Object.values(formats).forEach(f => nums.push(f.num)) let index = nums.indexOf(format) if (index === -1) { return new Response('Invalid format', { status: 400, headers: { 'content-type': 'text/plain' } }) } format = Object.keys(formats)[index] } } let tagging = query.t tagging = (tagging === 'true' || tagging === '1') && (['misc', '128', '320'].includes(format) || type !== 'track') switch (type) { case 'track': return await track(id, format, tagging, request.headers.get('range')) case 'album': case 'playlist': return await m3u8(type, id, format, tagging, request.headers.get('host')) } }) let license_token let checkForm let sid async function auth_gw() { license_token = await KV.get('license_token') checkForm = await KV.get('checkForm') sid = await KV.get('sid') if (license_token === null) { const user_data = await gw_api_call('deezer.getUserData') if (user_data.constructor.name === 'Response') { return user_data } if (user_data.USER.USER_ID === 0) { return new Response('Invalid arl', { status: 500, headers: { 'content-type': 'text/plain' } }) } license_token = user_data.USER.OPTIONS.license_token await KV.put('license_token', license_token, { expirationTtl: 3600 }) } } let access_token async function auth_dzapi() { access_token = await KV.get('access_token') if (access_token === null) { const response = await fetch(`https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=${client_id}&client_secret=${client_secret}&output=json`) const json = await response.json() if (json.error !== undefined) { return new Response("Couldn't get access token from Deezer", { status: 500, headers: { 'content-type': 'text/plain' } }) } access_token = json.access_token await KV.put('access_token', access_token, { expirationTtl: parseInt(json.expires) }) } } async function track(id, format, tagging, range_header) { // other users' user-upped tracks cannot be downloaded with the gw-light API let json; if (id >= 0) { let err_auth = await auth_gw() if (err_auth) { return err_auth } json = await gw_api_call('song.getData', { 'SNG_ID': id }) if (json.constructor.name === 'Response') { return json } } else { // user-upped track let err_auth = await auth_dzapi() if (err_auth) { return err_auth } const response = await fetch(`https://api.deezer.com/track/${id}?access_token=${access_token}`) json = await response.json() if (json.error !== undefined) { return new Response(JSON.stringify(json.error), { status: 403, headers: { 'content-type': "application/json" } }) } json = { SNG_ID: json.id, FILESIZE_MP3_MISC: json.filesize_misc, SNG_TITLE: json.title, ALB_TITLE: json.album.title, ART_NAME: json.artist.name, ARTISTS: [{ART_NAME: json.artist.name}], ISRC: '', ALB_PICTURE: json.md5_image, MD5_ORIGIN: json.md5_origin, MEDIA_VERSION: json.media_version, } format = 'misc' // user-upped tracks always use 'misc' as format } if (json['FILESIZE_' + formats[format].gw] == false) { return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } }) } let range_req = false let range_start = 0 let range_end = NaN if (range_header !== null) { const range_match = range_header.match(/^bytes=(\d*)-(\d*)/) if (range_match !== null) { range_start = parseInt(range_match[1]) range_end = parseInt(range_match[2]) range_req = true } } const wasm = await import('./pkg') let track_url let use_legacy_url = !['320', 'flac'].includes(format) if (!use_legacy_url) { // server-side stream url // needed if track has fallback, like https://www.deezer.com/track/11835714 let track_token if (json.FALLBACK !== undefined) { track_token = json.FALLBACK.TRACK_TOKEN } else { track_token = json.TRACK_TOKEN } const body = { license_token: license_token, media: [ { type: 'FULL', formats: [ { cipher: 'BF_CBC_STRIPE', format: formats[format].gw } ] } ], track_tokens: [ track_token ] } const init = { method: 'POST', body: JSON.stringify(body) } const resp = await fetch('https://media.deezer.com/v1/get_url', init) if (resp.status !== 200) { return new Response("Couldn't get stream URL", { status: 403, headers: { 'content-type': 'text/plain' } }) } const media_json = await resp.json() if (media_json.data[0].media !== undefined) { track_url = media_json.data[0].media[0].sources[0].url } else { return new Response("Couldn't get stream URL", { status: 403, headers: { 'content-type': 'text/plain' } }) } } else { // legacy stream url track_url = await legacy_track_url(json, format, wasm.legacy_stream_url) } let title = json.SNG_TITLE if (json.VERSION) title += ` ${json.VERSION}` let init = { method: 'GET', headers: {} } let resp_init = { status: 200, headers: { 'content-type': formats[format].mime, 'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`, 'accept-ranges': 'bytes' } } let id3_buffer let id3_len = 0 if (tagging) { let id3 = new ID3Writer(Buffer.alloc(0)); id3.padding = 0 id3.setFrame('TIT2', title) .setFrame('TALB', json.ALB_TITLE) .setFrame('TPE2', json.ART_NAME) if (json.ARTISTS !== undefined) { let artist_list = []; for (const a of json.ARTISTS) { artist_list.push(a.ART_NAME) } id3.setFrame('TPE1', artist_list) } if (json.TRACK_NUMBER !== undefined) { id3.setFrame('TRCK', json.TRACK_NUMBER) } if (json.DISK_NUMBER !== undefined) { id3.setFrame('TPOS', json.DISK_NUMBER) } if (json.ISRC !== '') { id3.setFrame('TSRC', json.ISRC) } if (json.PHYSICAL_RELEASE_DATE !== undefined) { const split = json.PHYSICAL_RELEASE_DATE.split('-') id3.setFrame('TYER', split[0]) id3.setFrame('TDAT', split[2] + split[1]) } if (json.ALB_PICTURE !== '') { const url = `https://cdns-images.dzcdn.net/images/cover/${json.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg` const cover = await fetch(url) const coverBuffer = await cover.arrayBuffer() id3.setFrame('APIC', { type: 3, data: coverBuffer, description: 'cover' }); } id3.addTag(); id3_buffer = id3.arrayBuffer id3_len = id3_buffer.byteLength } let skip_id3 = false let skip_stream = false let trk_range_start = 0 let trk_range_end = NaN let bytes_remove_start = 0 let bytes_remove_end = 0 if (range_req) { if (isNaN(range_start)) { range_start = 0 } if (!isNaN(range_end)) { range_end++ } if (range_start < 0 || range_end < range_start) { return new Response('Invalid range', { status: 416, headers: { 'content-type': 'text/plain' } }) } trk_range_start = range_start trk_range_end = range_end if (tagging) { if (trk_range_start < id3_len) { let bytes_remove_id3_end = undefined if (trk_range_end < id3_len && !isNaN(trk_range_end)) { bytes_remove_id3_end = id3_len - (id3_len - trk_range_end) skip_stream = true } else { trk_range_end -= id3_len } id3_buffer = id3_buffer.slice(trk_range_start, bytes_remove_id3_end) trk_range_start = 0 } else { skip_id3 = true trk_range_start -= id3_len trk_range_end -= id3_len } } if (!skip_stream) { bytes_remove_start = trk_range_start % 2048 bytes_remove_end = 2048 - trk_range_end % 2048 trk_range_start -= bytes_remove_start trk_range_end += bytes_remove_end let tmp_trk_range_end = trk_range_end if (isNaN(tmp_trk_range_end)) { tmp_trk_range_end = '' } else { tmp_trk_range_end-- } init.headers.range = `bytes=${trk_range_start}-${tmp_trk_range_end}` } else { init.method = 'HEAD' } } let track track = await fetch(track_url, init) if (![200, 206].includes(track.status)) { if (track.status === 416) { return new Response('Invalid range', { status: 416, headers: { 'content-type': 'text/plain' } }) } return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } }) } let track_size if (!range_req || skip_stream) { track_size = parseInt(track.headers.get('content-length')) } else { const range_header_match = track.headers.get('content-range').match(/^bytes (\d+)-(\d+)\/(\d+)/) track_size = parseInt(range_header_match[3]) } if (isNaN(range_end)) { range_end = track_size + id3_len trk_range_end = track_size } let resp_size = range_end - range_start if (range_req) { if (trk_range_end >= track_size) { bytes_remove_end = 0 } resp_init.status = 206 resp_init.headers['content-range'] = `bytes ${range_start}-${range_end - 1}/${track_size + id3_len}` } let { readable, writable } = new FixedLengthStream(resp_size) const writer = writable.getWriter() if (tagging && !skip_id3) { writer.write(id3_buffer) } if (!skip_stream) { // needed if track has fallback, like https://www.deezer.com/track/11835714 if (json.FALLBACK) { id = json.FALLBACK.SNG_ID } const cipher = new wasm.Cipher(String(id)) pipeDecryptedStream(writer, track.body, trk_range_end, cipher, trk_range_start, bytes_remove_start, bytes_remove_end) } else { writer.close() } return new Response(readable, resp_init) } async function pipeDecryptedStream(writer, body, length, cipher, byte_count, bytes_remove_start, bytes_remove_end) { const reader = body.getReader({ mode: 'byob' }) let end = false while (!end) { end = byte_count + 2048 >= length let chunk = (await reader.readAtLeast(2048, new Uint8Array(2048))).value if (byte_count % 6144 === 0 && chunk.byteLength == 2048) { // encrypted chunk cipher.decrypt_chunk(chunk) } if (bytes_remove_start !== 0) { chunk = chunk.slice(bytes_remove_start) bytes_remove_start = 0 } if (end && bytes_remove_end !== 0) { chunk = chunk.slice(0, chunk.byteLength - bytes_remove_end) } writer.write(chunk) byte_count += 2048 } reader.cancel() writer.close() } function legacy_track_url(json, format, url_func) { // needed if track has fallback, like https://www.deezer.com/track/11835714 if (json.FALLBACK) { json = json.FALLBACK } const id = json.SNG_ID.toString() const md5_origin = json.MD5_ORIGIN const media_version = json.MEDIA_VERSION format = formats[format].num return url_func(md5_origin, format, id, media_version) } async function m3u8(type, id, format, tagging, host) { const response = await fetch(`https://api.deezer.com/${type}/${id}?limit=-1`) const json = await response.json() if (json.error !== undefined) { return new Response(JSON.stringify(json.error), { status: 403, headers: { 'content-type': 'application/json' } }) } let list = '#EXTM3U\n' for (const track of json.tracks.data) { let format_tmp = format if (track.id < 0) { // user-uploaded track format_tmp = 'misc' } let result = `https://${host}/track/${track.id}?f=${format_tmp}&t=${+ tagging}` list += `#EXTINF:${track.duration},${track.title}\n${result}\n` } return new Response(list, { status: 200, headers: { 'content-type': 'audio/mpegurl' } }) } router.get('/', () => { return new Response(require('./index.html'), { status: 200, headers: { 'content-type': 'text/html' } }) }) router.all("*", () => new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } })) addEventListener('fetch', event => event.respondWith(router.handle(event.request)) )