const Router = require('./router') const aesjs = require('aes-js'); const ID3Writer = require('browser-id3-writer'); const Blowfish = require('./blowfish'); addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)) }) const trackCDNKey = [106, 111, 54, 97, 101, 121, 54, 104, 97, 105, 100, 50, 84, 101, 105, 104] const bfSecret = [103, 52, 101, 108, 53, 56, 119, 99, 48, 122, 118, 102, 57, 110, 97, 49] function str2bin(str) { return Array.from(str).map(function (item) { return item.charCodeAt(0); }) } function bin2str(bin) { return String.fromCharCode.apply(String, bin); } async function bf_key(id) { let id_md5 = await crypto.subtle.digest('MD5', new Uint8Array(str2bin(id))) id_md5 = aesjs.utils.hex.fromBytes(new Uint8Array(id_md5)) key = [] for (let i = 0; i < 16; i++) { key.push(id_md5[i].charCodeAt(0) ^ id_md5[i + 16].charCodeAt(0) ^ bfSecret[i]) } return bin2str(key) } async function url_gen(md5_origin, format, id, media_version) { const cipher = new aesjs.ModeOfOperation.ecb(trackCDNKey) let result = [md5_origin, format, id, media_version].join('\xa4') let result_md5 = await crypto.subtle.digest('MD5', new Uint8Array(str2bin(result))) result_md5 = aesjs.utils.hex.fromBytes(new Uint8Array(result_md5)) result = result_md5 + '\xa4' + result + '\xa4' // zero-padding if (result.length % 16) { result += '\x00'.repeat(16 - result.length % 16) } result = aesjs.utils.hex.fromBytes(cipher.encrypt(str2bin(result))) // cdn template with first character of md5 string + hash return `https://cdns-proxy-${md5_origin[0]}.dzcdn.net/mobile/1/${result}` } const format_string_to_num = { '64': '10', '128': '1', '320': '3', 'misc': '0', } async function handler(type, request) { 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=447462&client_secret=a83bf7f38ad2f137e444727cfc3775cf&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: Number(json.expires) }) } const url = new URL(request.url) const id = url.pathname.split('/')[2] if (id === "") { return new Response('ID needs to be specified', { status: 400, headers: { 'content-type': 'text/plain' } }) } format = url.searchParams.get('f') if (format === null) { format = '320' } else { format = format.toLowerCase() if (format_string_to_num[format] === undefined) { index = Object.values(format_string_to_num).indexOf(format) if (index === -1) { return new Response('Invalid format', { status: 400, headers: { 'content-type': 'text/plain' } }) } format = Object.keys(format_string_to_num)[index] } } var tagging = url.searchParams.get('t') tagging = tagging === 'true' || tagging === '1' switch (type) { case 'track': return await track(id, format, access_token, tagging) case 'album': case 'playlist': return await m3u8(type, id, format, access_token, tagging) } } async function track(id, format, access_token, tagging) { const response = await fetch(`https://api.deezer.com/track/${id}?access_token=${access_token}`) const json = await response.json() if (json.error !== undefined) { return new Response(JSON.stringify(json.error), { status: 403, headers: { 'content-type': "application/json" } }) } result = await track_url(json, format) if (typeof result === 'object') { return result } const track = await fetch(result) if (track.status !== 200) { return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } }) } let id3 if (tagging) { id3 = new ID3Writer(Buffer.alloc(0)); id3.padding = 0 id3.setFrame('TIT2', json.title) .setFrame('TALB', json.album.title) .setFrame('TPE2', json.artist.name) if (json.contributors !== undefined) { contr_list = []; for (const c of json.contributors) { contr_list.push(c.name) } id3.setFrame('TPE1', contr_list) } if (json.track_position !== undefined) { id3.setFrame('TRCK', json.track_position) } if (json.disk_number !== undefined) { id3.setFrame('TPOS', json.disk_number) } if (json.isrc !== "") { id3.setFrame('TSRC', json.isrc) } if (json.bpm !== undefined) { id3.setFrame('TBPM', json.bpm) } if (json.release_date !== undefined) { const split = json.release_date.split('-') id3.setFrame('TYER', split[0]) id3.setFrame('TDAT', split[2] + split[1]) } if (json.md5_image !== "") { const url = `https://cdns-images.dzcdn.net/images/cover/${json.md5_image}/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(); } let { readable, writable } = new TransformStream() const writer = writable.getWriter() if (id3) { writer.write(id3.arrayBuffer) } const bfKey = await bf_key(id) const length = Number(track.headers.get('Content-Length')) pipeDecryptedStream(writer, track.body, length, bfKey, id3) return new Response(readable, { status: 200, headers: { 'content-type': 'audio/mpeg' } }) } async function pipeDecryptedStream(writer, body, length, bfKey) { const cipher = new Blowfish(bfKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL) cipher.setIv('\x00\x01\x02\x03\x04\x05\x06\x07') const reader = body.getReader({ mode: 'byob' }) let byteCount = 0 let end = false while (!end) { end = byteCount + 2048 > length let chunk if (!end) { chunk = new Int8Array(2048) } else { chunk = new Int8Array(length - byteCount) } // if read chunk isn't 2048 bytes, read until it is // cause of retarded readable streams not having an option to specify min bytes let tempLength = 0 while (tempLength !== chunk.length) { let read = (await reader.read(new Int8Array(chunk.length - tempLength))).value chunk.set(read, tempLength) tempLength += read.length } if (byteCount % 6144 === 0 && !end) { // encrypted chunk chunk = cipher.decode(chunk, Blowfish.TYPE.UINT8_ARRAY) } writer.write(chunk) byteCount += 2048 } } async function track_url(json, format) { // needed if track has fallback, like https://www.deezer.com/track/1398759152 if (json.alternative) { json = json.alternative } const id = json.id const md5_origin = json.md5_origin const media_version = json.media_version if (id < 0) { // user-uploaded track format = 'misc' } if (json['filesize_' + format] === '0') { return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } }) } format = format_string_to_num[format] return await url_gen(md5_origin, format, id, media_version) } async function m3u8(type, id, format, access_token, tagging) { const response = await fetch(`https://api.deezer.com/${type}/${id}?access_token=${access_token}&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" } }) } var list = '#EXTM3U\n' for (const track of json.tracks.data) { if (track.id < 0) { // user-uploaded track format = 'misc' } let result = `https://dz.uhwot.workers.dev/track/${track.id}?f=${format}&t=${Number(tagging)}` list += `#EXTINF:${track.duration},${track.title}\n${result}\n` } return new Response(list, { status: 200, headers: { 'content-type': 'audio/mpegurl' } }) } async function handleRequest(request) { const r = new Router() r.get('/track/.*', () => handler('track', request)) r.get('/album/.*', () => handler('album', request)) r.get('/playlist/.*', () => handler('playlist', request)) const resp = await r.route(request) return resp }