added partial content support (not supported with tagging rn), added content-length header (fixes super aids), updated dependencies
This commit is contained in:
parent
873e1002b9
commit
ea88be5762
|
@ -8,10 +8,11 @@ edition = "2021"
|
|||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
block-modes = "0.8"
|
||||
blowfish = "0.8"
|
||||
aes = "0.7"
|
||||
blowfish = "0.9"
|
||||
aes = "0.8"
|
||||
md-5 = "0.10"
|
||||
cipher = { version = "0.4", features = ["block-padding"] }
|
||||
cbc = "0.1"
|
||||
hex = "0.4"
|
||||
|
||||
wasm-bindgen = "0.2"
|
||||
|
|
File diff suppressed because one or more lines are too long
218
index.js
218
index.js
|
@ -63,10 +63,6 @@ async function gw_api_call(method, params) {
|
|||
return json.results
|
||||
}
|
||||
|
||||
let license_token
|
||||
let checkForm
|
||||
let sid
|
||||
let access_token
|
||||
router.get('/:type/:id', async request => {
|
||||
const { query } = request
|
||||
let { type, id } = request.params
|
||||
|
@ -80,42 +76,6 @@ router.get('/:type/:id', async request => {
|
|||
return new Response("Invalid ID", { status: 400, headers: { 'content-type': 'text/plain' } })
|
||||
}
|
||||
|
||||
// cookie/token stuff isn't needed for m3u8 playlists
|
||||
if (type === 'track') {
|
||||
if (id >= 0) { // checks if not user-upped track
|
||||
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 })
|
||||
}
|
||||
} else {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format = query.f
|
||||
if (format === undefined) {
|
||||
format = '320'
|
||||
|
@ -138,22 +98,71 @@ router.get('/:type/:id', async request => {
|
|||
|
||||
switch (type) {
|
||||
case 'track':
|
||||
return await track(id, format, tagging)
|
||||
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'))
|
||||
}
|
||||
})
|
||||
|
||||
async function track(id, format, tagging) {
|
||||
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) {
|
||||
|
@ -176,10 +185,21 @@ async function track(id, format, tagging) {
|
|||
format = 'misc' // user-upped tracks always use 'misc' as format
|
||||
}
|
||||
|
||||
if (json['FILESIZE_' + formats[format].gw] == false) {
|
||||
let filesize = json['FILESIZE_' + formats[format].gw]
|
||||
if (filesize == false) {
|
||||
return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } })
|
||||
}
|
||||
|
||||
let range_start = null
|
||||
let range_end = null
|
||||
if (!tagging && 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])
|
||||
}
|
||||
}
|
||||
|
||||
const wasm = await import('./pkg')
|
||||
|
||||
let track_url
|
||||
|
@ -230,14 +250,60 @@ async function track(id, format, tagging) {
|
|||
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' } })
|
||||
}
|
||||
filesize = parseInt(filesize)
|
||||
|
||||
let title = json.SNG_TITLE
|
||||
if (json.VERSION) title += ` ${json.VERSION}`
|
||||
|
||||
let init = {}
|
||||
let resp_init = {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': formats[format].mime,
|
||||
'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`
|
||||
}
|
||||
}
|
||||
if (!tagging) {
|
||||
resp_init.headers['accept-ranges'] = 'bytes'
|
||||
}
|
||||
|
||||
let resp_size = filesize
|
||||
let fixed_range_start
|
||||
let fixed_range_end = filesize
|
||||
let bytes_remove_start = 0
|
||||
let bytes_remove_end = 0
|
||||
if (range_start !== null) {
|
||||
if (isNaN(range_end)) {
|
||||
range_end = filesize
|
||||
} else {
|
||||
range_end++
|
||||
}
|
||||
|
||||
if (range_start < 0 || range_start > filesize || range_end < range_start || range_end > filesize) {
|
||||
return new Response('Range invalid', { status: 416, headers: { 'content-type': 'text/plain' } })
|
||||
}
|
||||
|
||||
bytes_remove_start = range_start % 2048
|
||||
bytes_remove_end = 2048 - range_end % 2048
|
||||
|
||||
fixed_range_start = range_start - bytes_remove_start
|
||||
fixed_range_end = range_end + bytes_remove_end
|
||||
if (fixed_range_end >= filesize) {
|
||||
fixed_range_end = filesize
|
||||
bytes_remove_end = 0
|
||||
}
|
||||
|
||||
init = { headers: new Headers({ range: `bytes=${fixed_range_start}-${fixed_range_end - 1}` }) }
|
||||
resp_init.status = 206
|
||||
resp_init.headers['content-range'] = `bytes ${range_start}-${range_end - 1}/${filesize}`
|
||||
resp_size = range_end - range_start
|
||||
}
|
||||
|
||||
const track = await fetch(track_url, init)
|
||||
if (![200, 206].includes(track.status)) {
|
||||
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));
|
||||
|
@ -287,9 +353,11 @@ async function track(id, format, tagging) {
|
|||
}
|
||||
|
||||
id3.addTag();
|
||||
|
||||
resp_size += id3.arrayBuffer.byteLength
|
||||
}
|
||||
|
||||
let { readable, writable } = new TransformStream()
|
||||
let { readable, writable } = new FixedLengthStream(resp_size)
|
||||
const writer = writable.getWriter()
|
||||
|
||||
if (tagging) {
|
||||
|
@ -303,43 +371,46 @@ async function track(id, format, tagging) {
|
|||
|
||||
const cipher = new wasm.Cipher(String(id))
|
||||
|
||||
const length = parseInt(track.headers.get('Content-Length'))
|
||||
pipeDecryptedStream(writer, track.body, fixed_range_end, cipher, fixed_range_start, bytes_remove_start, bytes_remove_end)
|
||||
|
||||
pipeDecryptedStream(writer, track.body, length, cipher)
|
||||
|
||||
const headers = {
|
||||
'content-type': formats[format].mime,
|
||||
'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`
|
||||
return new Response(readable, resp_init)
|
||||
}
|
||||
|
||||
return new Response(readable, { status: 200, headers })
|
||||
}
|
||||
|
||||
async function pipeDecryptedStream(writer, body, length, cipher) {
|
||||
async function pipeDecryptedStream(writer, body, length, cipher, fixed_range_start, bytes_remove_start, bytes_remove_end) {
|
||||
const reader = body.getReader({ mode: 'byob' })
|
||||
let byteCount = 0
|
||||
let byte_count = 0
|
||||
if (fixed_range_start) {
|
||||
byte_count = fixed_range_start
|
||||
}
|
||||
let end = false
|
||||
while (!end) {
|
||||
end = byteCount + 2048 > length
|
||||
let minBytes
|
||||
end = byte_count + 2048 >= length
|
||||
let min_bytes
|
||||
if (!end) {
|
||||
minBytes = 2048
|
||||
min_bytes = 2048
|
||||
} else {
|
||||
minBytes = length - byteCount
|
||||
min_bytes = length - byte_count
|
||||
}
|
||||
|
||||
let chunk = (await reader.readAtLeast(minBytes, new Uint8Array(2048))).value
|
||||
let chunk = (await reader.readAtLeast(min_bytes, new Uint8Array(2048))).value
|
||||
|
||||
if (byteCount % 6144 === 0 && !end) {
|
||||
if (byte_count % 6144 === 0 && !end) {
|
||||
// encrypted chunk
|
||||
cipher.decrypt_chunk(chunk)
|
||||
}
|
||||
|
||||
writer.write(chunk)
|
||||
byteCount += 2048
|
||||
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.length - bytes_remove_end)
|
||||
}
|
||||
|
||||
byte_count += 2048
|
||||
await writer.write(chunk)
|
||||
}
|
||||
|
||||
reader.cancel()
|
||||
writer.close()
|
||||
}
|
||||
|
||||
|
@ -385,17 +456,6 @@ router.get('/', () => {
|
|||
|
||||
router.all("*", () => new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } }))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
addEventListener('fetch', event =>
|
||||
event.respondWith(router.handle(event.request))
|
||||
)
|
|
@ -14,9 +14,9 @@
|
|||
"serverless-cloudflare-workers": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wasm-tool/wasm-pack-plugin": "^1.5.0",
|
||||
"@wasm-tool/wasm-pack-plugin": "^1.6.0",
|
||||
"html-loader": "^1.3.2",
|
||||
"prettier": "^1.17.0"
|
||||
"prettier": "^2.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
|
@ -62,9 +62,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@wasm-tool/wasm-pack-plugin": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.5.0.tgz",
|
||||
"integrity": "sha512-qsGJ953zrXZdXW58cfYOh2nBXp0SYBsFhkxqh9p4JK8cXllEzHeRXoVO+qtgEB31+s1tsL8eda3Uy97W/7yOAg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.6.0.tgz",
|
||||
"integrity": "sha512-Iax4nEgIvVCZqrmuseJm7ln/muWpg7uT5fXMAT0crYo+k5JTuZE58DJvBQoeIAegA3IM9cZgfkcZjAOUCPsT1g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chalk": "^2.4.1",
|
||||
|
@ -3170,15 +3170,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
|
||||
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
|
@ -4851,9 +4851,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"@wasm-tool/wasm-pack-plugin": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.5.0.tgz",
|
||||
"integrity": "sha512-qsGJ953zrXZdXW58cfYOh2nBXp0SYBsFhkxqh9p4JK8cXllEzHeRXoVO+qtgEB31+s1tsL8eda3Uy97W/7yOAg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.6.0.tgz",
|
||||
"integrity": "sha512-Iax4nEgIvVCZqrmuseJm7ln/muWpg7uT5fXMAT0crYo+k5JTuZE58DJvBQoeIAegA3IM9cZgfkcZjAOUCPsT1g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.4.1",
|
||||
|
@ -7458,9 +7458,9 @@
|
|||
"integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
|
||||
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
|
||||
"dev": true
|
||||
},
|
||||
"process": {
|
||||
|
|
|
@ -10,9 +10,9 @@
|
|||
"author": "uh_wot <uhwot@protonmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@wasm-tool/wasm-pack-plugin": "^1.5.0",
|
||||
"@wasm-tool/wasm-pack-plugin": "^1.6.0",
|
||||
"html-loader": "^1.3.2",
|
||||
"prettier": "^1.17.0"
|
||||
"prettier": "^2.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"browser-id3-writer": "^4.4.0",
|
||||
|
|
21
src/lib.rs
21
src/lib.rs
|
@ -1,20 +1,21 @@
|
|||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use aes::Aes128Enc;
|
||||
use blowfish::Blowfish;
|
||||
use aes::Aes128;
|
||||
use block_modes::{BlockMode, Cbc, Ecb};
|
||||
use block_modes::block_padding::{NoPadding, ZeroPadding};
|
||||
use md5::{Md5, Digest};
|
||||
use cipher::{
|
||||
BlockEncrypt, BlockDecryptMut, KeyInit, KeyIvInit,
|
||||
block_padding::{NoPadding, ZeroPadding}
|
||||
};
|
||||
|
||||
type BfCbc = Cbc<Blowfish, NoPadding>;
|
||||
type Aes128Ecb = Ecb<Aes128, ZeroPadding>;
|
||||
type BfCbcDec = cbc::Decryptor<Blowfish>;
|
||||
|
||||
const TRACK_CDN_KEY: [u8; 16] = [106, 111, 54, 97, 101, 121, 54, 104, 97, 105, 100, 50, 84, 101, 105, 104];
|
||||
const BF_SECRET: [u8; 16] = [103, 52, 101, 108, 53, 56, 119, 99, 48, 122, 118, 102, 57, 110, 97, 49];
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Cipher {
|
||||
cipher: BfCbc
|
||||
cipher: BfCbcDec
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
@ -27,11 +28,11 @@ impl Cipher {
|
|||
for i in 0..16 {
|
||||
key[i] = id_md5[i] ^ id_md5[i+16] ^ BF_SECRET[i]
|
||||
};
|
||||
Self { cipher: BfCbc::new_from_slices(&key, &[0, 1, 2, 3, 4, 5, 6, 7]).unwrap() }
|
||||
Self { cipher: BfCbcDec::new_from_slices(&key, &[0, 1, 2, 3, 4, 5, 6, 7]).unwrap() }
|
||||
}
|
||||
|
||||
pub fn decrypt_chunk(&self, chunk: &mut [u8]) {
|
||||
self.cipher.clone().decrypt(chunk).unwrap();
|
||||
self.cipher.clone().decrypt_padded_mut::<NoPadding>(chunk).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,8 +59,8 @@ pub fn legacy_stream_url(md5_origin: &str, format: &str, id: &str, media_version
|
|||
&[b'\xa4'],
|
||||
].concat();
|
||||
|
||||
let cipher = Aes128Ecb::new_from_slices(&TRACK_CDN_KEY, Default::default()).unwrap();
|
||||
let ciphertext = cipher.encrypt_vec(&metadata_hash);
|
||||
let cipher = Aes128Enc::new_from_slice(&TRACK_CDN_KEY).unwrap();
|
||||
let ciphertext = cipher.encrypt_padded_vec::<ZeroPadding>(&metadata_hash);
|
||||
|
||||
format!("https://cdns-proxy-{}.dzcdn.net/mobile/1/{}", md5_origin.chars().next().unwrap(), hex::encode(ciphertext))
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ workers_dev = true
|
|||
kv_namespaces = [
|
||||
{ binding = "KV", id = "974c0967a84e415daa054bbbcc7f80c6", preview_id = "cfcc6491f3484cbca664913836635113" }
|
||||
]
|
||||
compatibility_date = "2022-01-23"
|
||||
compatibility_date = "2022-02-27"
|
||||
|
||||
# [secrets]
|
||||
# ARL
|
Loading…
Reference in New Issue