344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
const Router = require('./router')
|
|
const ID3Writer = require('browser-id3-writer');
|
|
|
|
addEventListener('fetch', event => {
|
|
event.respondWith(handleRequest(event.request))
|
|
})
|
|
|
|
const formats = {
|
|
aac_96: { num: '8', gw: 'AAC_96', mime: 'audio/aac' },
|
|
64: { num: '10', gw: 'MP3_64', mime: 'audio/mpeg' },
|
|
128: { num: '1', gw: 'MP3_128', mime: 'audio/mpeg' },
|
|
320: { num: '3', gw: 'MP3_320', mime: 'audio/mpeg' },
|
|
flac: { num: '9', gw: 'FLAC', mime: 'audio/flac' },
|
|
mp4_ra1: { num: '13', gw: 'MP4_RA1', mime: 'audio/mp4' },
|
|
mp4_ra2: { num: '14', gw: 'MP4_RA2', mime: 'audio/mp4' },
|
|
mp4_ra3: { num: '15', gw: 'MP4_RA3', mime: 'audio/mp4' },
|
|
mhm1_ra1: { num: '16', gw: 'MHM1_RA1', mime: 'audio/mp4' },
|
|
mhm1_ra2: { num: '17', gw: 'MHM1_RA2', mime: 'audio/mp4' },
|
|
mhm1_ra3: { num: '18', gw: 'MHM1_RA3', mime: 'audio/mp4' },
|
|
sbc_256: { num: '12', gw: 'SBC_256', mime: '' },
|
|
misc: { num: '0', gw: 'MP3_MISC', mime: 'audio/mpeg' },
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function handler(type, request) {
|
|
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 })
|
|
}
|
|
|
|
const url = new URL(request.url)
|
|
const id = url.pathname.split('/')[2]
|
|
|
|
format = url.searchParams.get('f')
|
|
if (format === null) {
|
|
format = '320'
|
|
} else {
|
|
format = format.toLowerCase()
|
|
if (formats[format] === undefined) {
|
|
let nums = []
|
|
Object.values(formats).forEach(f => nums.push(f.num))
|
|
|
|
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 = url.searchParams.get('t')
|
|
tagging = (tagging === 'true' || tagging === '1') && ['misc', '128', '320'].includes(format)
|
|
|
|
switch (type) {
|
|
case 'track':
|
|
return await track(id, format, tagging)
|
|
case 'album':
|
|
case 'playlist':
|
|
return await m3u8(type, id, format, tagging, url.host)
|
|
}
|
|
}
|
|
|
|
async function track(id, format, tagging) {
|
|
const json = await gw_api_call('song.getData', { 'SNG_ID': id })
|
|
if (json.constructor.name === 'Response') {
|
|
return json
|
|
}
|
|
|
|
if (parseInt(json.SNG_ID) < 0) { // user-uploaded track
|
|
format = 'misc'
|
|
}
|
|
|
|
if (json['FILESIZE_' + formats[format].gw] == false) {
|
|
return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } })
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
const track = await fetch(track_url)
|
|
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
|
|
|
|
let title = json.SNG_TITLE
|
|
if (json.VERSION !== undefined) title += ` ${json.VERSION}`
|
|
id3.setFrame('TIT2', title)
|
|
.setFrame('TALB', json.ALB_TITLE)
|
|
.setFrame('TPE2', json.ART_NAME)
|
|
|
|
if (json.ARTISTS !== undefined) {
|
|
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();
|
|
}
|
|
|
|
let { readable, writable } = new TransformStream()
|
|
const writer = writable.getWriter()
|
|
|
|
if (tagging) {
|
|
writer.write(id3.arrayBuffer)
|
|
}
|
|
|
|
// 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(id)
|
|
|
|
const length = parseInt(track.headers.get('Content-Length'))
|
|
|
|
pipeDecryptedStream(writer, track.body, length, cipher)
|
|
|
|
return new Response(readable, { status: 200, headers: { 'content-type': formats[format].mime } })
|
|
}
|
|
|
|
async function pipeDecryptedStream(writer, body, length, cipher) {
|
|
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
|
|
cipher.decrypt_chunk(chunk)
|
|
}
|
|
|
|
await writer.write(chunk)
|
|
byteCount += 2048
|
|
}
|
|
|
|
await reader.cancel()
|
|
await 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) {
|
|
if (track.id < 0) { // user-uploaded track
|
|
format = 'misc'
|
|
}
|
|
let result = `https://${host}/track/${track.id}?f=${format}&t=${+ tagging}`
|
|
list += `#EXTINF:${track.duration},${track.title}\n${result}\n`
|
|
}
|
|
|
|
return new Response(list, { status: 200, headers: { 'content-type': 'audio/mpegurl' } })
|
|
}
|
|
|
|
async function indexHandler() {
|
|
return new Response(require('./index.html'), { status: 200, headers: { 'content-type': 'text/html' } })
|
|
}
|
|
|
|
async function handleRequest(request) {
|
|
const r = new Router()
|
|
r.get('/', () => indexHandler())
|
|
r.get('/track/-?\\d+', () => handler('track', request))
|
|
r.get('/album/\\d+', () => handler('album', request))
|
|
r.get('/playlist/\\d+', () => handler('playlist', request))
|
|
|
|
const resp = await r.route(request)
|
|
return resp
|
|
}
|