2021-01-24 14:41:29 +00:00
|
|
|
const Router = require('./router')
|
2021-01-25 00:39:01 +00:00
|
|
|
const aesjs = require('aes-js');
|
2021-05-25 19:00:16 +00:00
|
|
|
const ID3Writer = require('browser-id3-writer');
|
2021-07-30 17:34:39 +00:00
|
|
|
const Blowfish = require('./blowfish');
|
2021-01-24 14:41:29 +00:00
|
|
|
|
|
|
|
addEventListener('fetch', event => {
|
|
|
|
event.respondWith(handleRequest(event.request))
|
|
|
|
})
|
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
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]
|
2021-01-25 00:39:01 +00:00
|
|
|
|
2021-07-16 22:46:25 +00:00
|
|
|
function str2bin(str) {
|
2021-07-17 13:39:30 +00:00
|
|
|
return Array.from(str).map(function (item) {
|
2021-07-16 22:46:25 +00:00
|
|
|
return item.charCodeAt(0);
|
2021-07-17 13:39:30 +00:00
|
|
|
})
|
2021-07-16 22:46:25 +00:00
|
|
|
}
|
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2021-01-25 01:02:47 +00:00
|
|
|
async function url_gen(md5_origin, format, id, media_version) {
|
2021-07-30 17:34:39 +00:00
|
|
|
const cipher = new aesjs.ModeOfOperation.ecb(trackCDNKey)
|
2021-01-25 01:02:47 +00:00
|
|
|
|
2021-07-16 22:46:25 +00:00
|
|
|
let result = [md5_origin, format, id, media_version].join('\xa4')
|
2021-01-25 01:02:47 +00:00
|
|
|
|
2021-07-17 13:39:30 +00:00
|
|
|
let result_md5 = await crypto.subtle.digest('MD5', new Uint8Array(str2bin(result)))
|
2021-01-25 01:02:47 +00:00
|
|
|
|
2021-07-16 22:46:25 +00:00
|
|
|
result_md5 = aesjs.utils.hex.fromBytes(new Uint8Array(result_md5))
|
2021-01-25 01:02:47 +00:00
|
|
|
|
2021-07-16 22:46:25 +00:00
|
|
|
result = result_md5 + '\xa4' + result + '\xa4'
|
2021-01-25 01:02:47 +00:00
|
|
|
|
2021-07-16 22:46:25 +00:00
|
|
|
// zero-padding
|
|
|
|
if (result.length % 16) {
|
|
|
|
result += '\x00'.repeat(16 - result.length % 16)
|
|
|
|
}
|
|
|
|
|
|
|
|
result = aesjs.utils.hex.fromBytes(cipher.encrypt(str2bin(result)))
|
2021-01-25 01:02:47 +00:00
|
|
|
|
|
|
|
// cdn template with first character of md5 string + hash
|
2021-07-30 17:34:39 +00:00
|
|
|
return `https://cdns-proxy-${md5_origin[0]}.dzcdn.net/mobile/1/${result}`
|
2021-01-25 00:39:01 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 08:29:09 +00:00
|
|
|
const format_string_to_num = {
|
|
|
|
'64': '10',
|
|
|
|
'128': '1',
|
|
|
|
'320': '3',
|
|
|
|
'misc': '0',
|
|
|
|
}
|
|
|
|
|
2021-01-26 11:55:28 +00:00
|
|
|
async function handler(type, request) {
|
2021-01-26 15:02:54 +00:00
|
|
|
access_token = await KV.get('access_token')
|
2021-01-25 00:39:01 +00:00
|
|
|
if (access_token === null) {
|
2021-01-26 15:02:54 +00:00
|
|
|
const response = await fetch('https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=447462&client_secret=a83bf7f38ad2f137e444727cfc3775cf&output=json')
|
2021-01-25 00:39:01 +00:00
|
|
|
const json = await response.json()
|
|
|
|
if (json.error !== undefined) {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response("Couldn't get access token from Deezer", { status: 500, headers: { 'content-type': 'text/plain' } })
|
2021-01-25 00:39:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
access_token = json.access_token
|
2021-07-30 17:34:39 +00:00
|
|
|
await KV.put('access_token', access_token, { expirationTtl: Number(json.expires) })
|
2021-01-25 00:39:01 +00:00
|
|
|
}
|
|
|
|
|
2021-01-26 11:55:28 +00:00
|
|
|
const url = new URL(request.url)
|
|
|
|
const id = url.pathname.split('/')[2]
|
|
|
|
|
|
|
|
if (id === "") {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response('ID needs to be specified', { status: 400, headers: { 'content-type': 'text/plain' } })
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
2021-01-25 00:39:01 +00:00
|
|
|
|
2021-01-25 08:29:09 +00:00
|
|
|
format = url.searchParams.get('f')
|
|
|
|
if (format === null) {
|
2021-01-26 15:02:54 +00:00
|
|
|
format = '320'
|
2021-01-25 08:29:09 +00:00
|
|
|
} else {
|
|
|
|
format = format.toLowerCase()
|
|
|
|
if (format_string_to_num[format] === undefined) {
|
|
|
|
index = Object.values(format_string_to_num).indexOf(format)
|
|
|
|
if (index === -1) {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response('Invalid format', { status: 400, headers: { 'content-type': 'text/plain' } })
|
2021-01-25 08:29:09 +00:00
|
|
|
}
|
|
|
|
format = Object.keys(format_string_to_num)[index]
|
|
|
|
}
|
|
|
|
}
|
2021-07-30 17:34:39 +00:00
|
|
|
|
2021-06-07 10:32:12 +00:00
|
|
|
var tagging = url.searchParams.get('t')
|
2021-07-30 17:34:39 +00:00
|
|
|
tagging = tagging === 'true' || tagging === '1'
|
2021-05-25 19:00:16 +00:00
|
|
|
|
2021-01-26 11:55:28 +00:00
|
|
|
switch (type) {
|
|
|
|
case 'track':
|
2021-05-25 19:00:16 +00:00
|
|
|
return await track(id, format, access_token, tagging)
|
2021-01-26 11:55:28 +00:00
|
|
|
case 'album':
|
|
|
|
case 'playlist':
|
2021-05-25 21:57:29 +00:00
|
|
|
return await m3u8(type, id, format, access_token, tagging)
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-25 19:00:16 +00:00
|
|
|
async function track(id, format, access_token, tagging) {
|
2021-01-26 11:55:28 +00:00
|
|
|
const response = await fetch(`https://api.deezer.com/track/${id}?access_token=${access_token}`)
|
|
|
|
const json = await response.json()
|
|
|
|
if (json.error !== undefined) {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response(JSON.stringify(json.error), { status: 403, headers: { 'content-type': "application/json" } })
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
result = await track_url(json, format)
|
|
|
|
if (typeof result === 'object') {
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
const track = await fetch(result)
|
|
|
|
if (track.status !== 200) {
|
|
|
|
return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } })
|
|
|
|
}
|
2021-05-25 19:00:16 +00:00
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
let id3
|
|
|
|
if (tagging) {
|
|
|
|
id3 = new ID3Writer(Buffer.alloc(0));
|
2021-05-25 21:42:00 +00:00
|
|
|
id3.padding = 0
|
|
|
|
id3.setFrame('TIT2', json.title)
|
2021-05-25 19:00:16 +00:00
|
|
|
.setFrame('TALB', json.album.title)
|
2021-06-02 22:55:29 +00:00
|
|
|
.setFrame('TPE2', json.artist.name)
|
2021-05-25 19:00:16 +00:00
|
|
|
|
|
|
|
if (json.contributors !== undefined) {
|
|
|
|
contr_list = [];
|
|
|
|
for (const c of json.contributors) {
|
|
|
|
contr_list.push(c.name)
|
|
|
|
}
|
|
|
|
|
2021-05-25 21:42:00 +00:00
|
|
|
id3.setFrame('TPE1', contr_list)
|
2021-05-25 19:00:16 +00:00
|
|
|
}
|
|
|
|
|
2021-06-02 22:55:29 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2021-05-25 19:00:16 +00:00
|
|
|
if (json.release_date !== undefined) {
|
2021-06-02 22:55:29 +00:00
|
|
|
const split = json.release_date.split('-')
|
|
|
|
id3.setFrame('TYER', split[0])
|
2021-07-30 17:34:39 +00:00
|
|
|
id3.setFrame('TDAT', split[2] + split[1])
|
2021-05-25 19:00:16 +00:00
|
|
|
}
|
|
|
|
|
2021-06-05 22:57:11 +00:00
|
|
|
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)
|
2021-05-25 19:00:16 +00:00
|
|
|
const coverBuffer = await cover.arrayBuffer()
|
|
|
|
|
2021-05-25 21:42:00 +00:00
|
|
|
id3.setFrame('APIC', {
|
2021-05-25 19:00:16 +00:00
|
|
|
type: 3,
|
|
|
|
data: coverBuffer,
|
|
|
|
description: 'cover'
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-05-25 21:42:00 +00:00
|
|
|
id3.addTag();
|
2021-07-30 17:34:39 +00:00
|
|
|
}
|
2021-05-25 19:00:16 +00:00
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
let { readable, writable } = new TransformStream()
|
|
|
|
const writer = writable.getWriter()
|
2021-05-25 21:42:00 +00:00
|
|
|
|
2021-07-31 23:57:30 +00:00
|
|
|
if (id3) {
|
2021-05-25 21:42:00 +00:00
|
|
|
writer.write(id3.arrayBuffer)
|
2021-07-30 17:34:39 +00:00
|
|
|
}
|
2021-05-25 21:42:00 +00:00
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
const bfKey = await bf_key(id)
|
2021-07-31 23:57:30 +00:00
|
|
|
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) {
|
2021-07-30 17:34:39 +00:00
|
|
|
const cipher = new Blowfish(bfKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL)
|
|
|
|
cipher.setIv('\x00\x01\x02\x03\x04\x05\x06\x07')
|
|
|
|
|
2021-07-31 23:57:30 +00:00
|
|
|
const reader = body.getReader({ mode: 'byob' })
|
2021-07-30 17:34:39 +00:00
|
|
|
let byteCount = 0
|
|
|
|
let end = false
|
|
|
|
while (!end) {
|
2021-07-31 23:57:30 +00:00
|
|
|
end = byteCount + 2048 > length
|
|
|
|
let chunk
|
|
|
|
if (!end) {
|
|
|
|
chunk = new Int8Array(2048)
|
|
|
|
} else {
|
|
|
|
chunk = new Int8Array(length - byteCount)
|
2021-07-30 17:34:39 +00:00
|
|
|
}
|
2021-07-31 23:57:30 +00:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
if (byteCount % 6144 === 0 && !end) {
|
|
|
|
// encrypted chunk
|
|
|
|
chunk = cipher.decode(chunk, Blowfish.TYPE.UINT8_ARRAY)
|
|
|
|
}
|
2021-07-31 23:57:30 +00:00
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
writer.write(chunk)
|
|
|
|
byteCount += 2048
|
2021-05-25 19:00:16 +00:00
|
|
|
}
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function track_url(json, format) {
|
2021-07-19 13:43:07 +00:00
|
|
|
// needed if track has fallback, like https://www.deezer.com/track/1398759152
|
|
|
|
if (json.alternative) {
|
|
|
|
json = json.alternative
|
|
|
|
}
|
|
|
|
|
2021-01-26 11:55:28 +00:00
|
|
|
const id = json.id
|
|
|
|
const md5_origin = json.md5_origin
|
|
|
|
const media_version = json.media_version
|
|
|
|
|
2021-06-21 15:30:57 +00:00
|
|
|
if (id < 0) { // user-uploaded track
|
2021-01-26 15:02:54 +00:00
|
|
|
format = 'misc'
|
|
|
|
}
|
|
|
|
|
2021-01-25 08:29:09 +00:00
|
|
|
if (json['filesize_' + format] === '0') {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } })
|
2021-01-25 08:29:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
format = format_string_to_num[format]
|
2021-01-25 00:39:01 +00:00
|
|
|
|
2021-01-26 11:55:28 +00:00
|
|
|
return await url_gen(md5_origin, format, id, media_version)
|
|
|
|
}
|
|
|
|
|
2021-05-25 21:57:29 +00:00
|
|
|
async function m3u8(type, id, format, access_token, tagging) {
|
2021-01-26 11:55:28 +00:00
|
|
|
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) {
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response(JSON.stringify(json.error), { status: 403, headers: { 'content-type': "application/json" } })
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
|
|
|
|
2021-01-26 15:02:54 +00:00
|
|
|
var list = '#EXTM3U\n'
|
2021-01-26 11:55:28 +00:00
|
|
|
|
|
|
|
for (const track of json.tracks.data) {
|
2021-07-30 17:34:39 +00:00
|
|
|
if (track.id < 0) { // user-uploaded track
|
|
|
|
format = 'misc'
|
2021-01-26 11:55:28 +00:00
|
|
|
}
|
2021-07-30 17:34:39 +00:00
|
|
|
let result = `https://dz.uhwot.workers.dev/track/${track.id}?f=${format}&t=${Number(tagging)}`
|
2021-01-26 11:55:28 +00:00
|
|
|
list += `#EXTINF:${track.duration},${track.title}\n${result}\n`
|
|
|
|
}
|
2021-01-25 08:29:09 +00:00
|
|
|
|
2021-07-30 17:34:39 +00:00
|
|
|
return new Response(list, { status: 200, headers: { 'content-type': 'audio/mpegurl' } })
|
2021-01-24 14:41:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async function handleRequest(request) {
|
|
|
|
const r = new Router()
|
2021-01-26 11:55:28 +00:00
|
|
|
r.get('/track/.*', () => handler('track', request))
|
|
|
|
r.get('/album/.*', () => handler('album', request))
|
|
|
|
r.get('/playlist/.*', () => handler('playlist', request))
|
2021-01-24 14:41:29 +00:00
|
|
|
|
|
|
|
const resp = await r.route(request)
|
|
|
|
return resp
|
|
|
|
}
|