dzserver/main.js

513 lines
16 KiB
JavaScript
Raw Normal View History

import html from './index.html'
import { Cipher, legacy_stream_url } from './wasm/pkg'
import { Router } from 'itty-router'
import ID3Writer from 'browser-id3-writer'
2021-01-24 14:41:29 +00:00
const router = Router()
2021-01-24 14:41:29 +00:00
2021-11-09 23:15:28 +00:00
const client_id = "447462"
const client_secret = "a83bf7f38ad2f137e444727cfc3775cf"
2021-09-02 00:57:42 +00:00
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),
}
2021-09-01 23:53:25 +00:00
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' } })
}
2021-09-01 20:20:58 +00:00
if (method === 'deezer.getUserData') {
checkForm = json.results.checkForm
await KV.put('checkForm', checkForm)
sid = json.results.SESSION_ID
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 (isNaN(id) || (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()
2021-09-02 00:57:42 +00:00
if (formats[format] === undefined) {
let nums = []
Object.values(formats).forEach(f => nums.push(f.num))
let index = nums.indexOf(format)
2021-08-19 14:49:56 +00:00
if (index === -1) {
return new Response('Invalid format', { status: 400, headers: { 'content-type': 'text/plain' } })
}
2021-09-02 00:57:42 +00:00
format = Object.keys(formats)[index]
}
}
let tagging = query.t
tagging = (tagging === 'true' || tagging === '1') && (['misc', '128', '320'].includes(format) || type !== 'track')
2021-05-25 19:00:16 +00:00
2021-01-26 11:55:28 +00:00
switch (type) {
case 'track':
return await track(id, format, tagging, request.headers.get('range'))
2021-01-26 11:55:28 +00:00
case 'album':
case 'playlist':
2021-12-03 19:10:49 +00:00
return await m3u8(type, id, format, tagging, request.headers.get('host'))
2021-01-26 11:55:28 +00:00
}
})
2021-01-26 11:55:28 +00:00
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) {
2021-11-09 23:15:28 +00:00
// 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
}
2021-11-09 23:15:28 +00:00
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
}
2021-11-09 23:15:28 +00:00
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,
2021-11-09 23:15:28 +00:00
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,
}
2021-01-26 11:55:28 +00:00
2021-11-09 23:15:28 +00:00
format = 'misc' // user-upped tracks always use 'misc' as format
2021-09-01 13:11:17 +00:00
}
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
}
}
let track_url
let use_legacy_url = !['320', 'flac'].includes(format)
if (!use_legacy_url) { // server-side stream url
2021-09-01 14:12:33 +00:00
// 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
2021-09-01 14:12:33 +00:00
} else {
track_token = json.TRACK_TOKEN
2021-09-01 14:12:33 +00:00
}
const body = {
license_token: license_token,
media: [
{
2021-09-01 20:20:58 +00:00
type: 'FULL',
formats: [
{
2021-09-01 20:20:58 +00:00
cipher: 'BF_CBC_STRIPE',
2021-09-02 00:57:42 +00:00
format: formats[format].gw
}
]
}
],
track_tokens: [ track_token ]
}
const init = {
method: 'POST',
body: JSON.stringify(body)
}
2021-09-01 13:01:56 +00:00
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' } })
2021-09-01 13:01:56 +00:00
}
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' } })
2021-09-01 13:01:56 +00:00
}
} else { // legacy stream url
track_url = legacy_track_url(json, format)
2021-01-26 11:55:28 +00:00
}
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(new Uint8Array());
2021-05-25 21:42:00 +00:00
id3.padding = 0
2021-10-14 15:39:34 +00:00
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)
2021-05-25 19:00:16 +00:00
}
id3.setFrame('TPE1', artist_list)
2021-06-02 22:55:29 +00:00
}
if (json.TRACK_NUMBER !== undefined) {
id3.setFrame('TRCK', json.TRACK_NUMBER)
2021-06-02 22:55:29 +00:00
}
if (json.DISK_NUMBER !== undefined) {
id3.setFrame('TPOS', json.DISK_NUMBER)
2021-06-02 22:55:29 +00:00
}
2021-09-01 20:20:58 +00:00
if (json.ISRC !== '') {
id3.setFrame('TSRC', json.ISRC)
2021-06-02 22:55:29 +00:00
}
if (json.PHYSICAL_RELEASE_DATE !== undefined) {
const split = json.PHYSICAL_RELEASE_DATE.split('-')
2021-06-02 22:55:29 +00:00
id3.setFrame('TYER', split[0])
id3.setFrame('TDAT', split[2] + split[1])
2021-05-25 19:00:16 +00:00
}
2021-09-01 20:20:58 +00:00
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)
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();
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}`
}
2021-05-25 19:00:16 +00:00
let { readable, writable } = new FixedLengthStream(resp_size)
const writer = writable.getWriter()
2021-05-25 21:42:00 +00:00
if (tagging && !skip_id3) {
writer.write(id3_buffer)
}
2021-05-25 21:42:00 +00:00
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 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' })
2022-03-09 22:19:08 +00:00
let end = false
while (!end) {
end = byte_count + 2048 >= length
let chunk = (await reader.readAtLeast(2048, new Uint8Array(2048))).value
2022-03-09 22:19:08 +00:00
if (byte_count % 6144 === 0 && chunk.byteLength == 2048) {
// encrypted chunk
2021-08-23 16:55:33 +00:00
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) {
2022-03-09 22:19:08 +00:00
chunk = chunk.slice(0, chunk.byteLength - bytes_remove_end)
}
2022-03-09 22:24:15 +00:00
writer.write(chunk)
byte_count += 2048
2021-05-25 19:00:16 +00:00
}
reader.cancel()
writer.close()
2021-01-26 11:55:28 +00:00
}
function legacy_track_url(json, format) {
2021-08-01 18:35:56 +00:00
// needed if track has fallback, like https://www.deezer.com/track/11835714
if (json.FALLBACK) {
json = json.FALLBACK
2021-07-19 13:43:07 +00:00
}
const id = json.SNG_ID.toString()
const md5_origin = json.MD5_ORIGIN
const media_version = json.MEDIA_VERSION
2021-09-02 00:57:42 +00:00
format = formats[format].num
2021-01-25 00:39:01 +00:00
return legacy_stream_url(md5_origin, format, id, media_version)
2021-01-26 11:55:28 +00:00
}
2021-09-01 13:01:56 +00:00
async function m3u8(type, id, format, tagging, host) {
const response = await fetch(`https://api.deezer.com/${type}/${id}?limit=-1`)
2021-01-26 11:55:28 +00:00
const json = await response.json()
if (json.error !== undefined) {
2021-09-01 20:20:58 +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-08-13 23:18:05 +00:00
let list = '#EXTM3U\n'
2021-01-26 11:55:28 +00:00
for (const track of json.tracks.data) {
let format_tmp = format
if (track.id < 0) { // user-uploaded track
format_tmp = 'misc'
2021-01-26 11:55:28 +00:00
}
const result = `https://${host}/track/${track.id}?f=${format_tmp}&t=${+ tagging}`
2021-01-26 11:55:28 +00:00
list += `#EXTINF:${track.duration},${track.title}\n${result}\n`
}
return new Response(list, { status: 200, headers: { 'content-type': 'audio/mpegurl' } })
2021-01-24 14:41:29 +00:00
}
router.get('/', () => {
return new Response(html, { status: 200, headers: { 'content-type': 'text/html' } })
})
router.all("*", () => new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } }))
2021-08-16 17:52:51 +00:00
addEventListener('fetch', event =>
event.respondWith(router.handle(event.request))
)