2020-08-28 23:06:19 +02:00
|
|
|
const crypto = require('crypto');
|
|
|
|
const axios = require('axios');
|
|
|
|
const decryptor = require('nodeezcryptor');
|
|
|
|
const querystring = require('querystring');
|
2020-10-31 16:54:28 +01:00
|
|
|
const https = require('https');
|
|
|
|
const {Transform, Readable} = require('stream');
|
2020-09-28 12:04:19 +02:00
|
|
|
const {Track} = require('./definitions');
|
|
|
|
const logger = require('./winston');
|
2020-08-28 23:06:19 +02:00
|
|
|
|
|
|
|
class DeezerAPI {
|
|
|
|
|
|
|
|
constructor(arl, electron = false) {
|
|
|
|
this.arl = arl;
|
|
|
|
this.electron = electron;
|
|
|
|
this.url = 'https://www.deezer.com/ajax/gw-light.php';
|
|
|
|
}
|
|
|
|
|
|
|
|
//Get headers
|
|
|
|
headers() {
|
|
|
|
let cookie = `arl=${this.arl}`;
|
|
|
|
if (this.sid) cookie += `; sid=${this.sid}`;
|
|
|
|
return {
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36",
|
|
|
|
"Content-Language": "en-US",
|
|
|
|
"Cache-Control": "max-age=0",
|
|
|
|
"Accept": "*/*",
|
|
|
|
"Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3",
|
|
|
|
"Accept-Language": "en-US,en;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
|
|
"Connection": 'keep-alive',
|
|
|
|
"Cookie": cookie
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//Wrapper for api calls, because axios doesn't work reliably with electron
|
|
|
|
async callApi(method, args = {}, gatewayInput = null) {
|
|
|
|
if (this.electron) return await this._callApiElectronNet(method, args, gatewayInput);
|
|
|
|
return await this._callApiAxios(method, args, gatewayInput);
|
|
|
|
}
|
|
|
|
|
|
|
|
//gw_light api call using axios, unstable in electron
|
|
|
|
async _callApiAxios(method, args = {}, gatewayInput = null) {
|
|
|
|
let data = await axios({
|
|
|
|
url: this.url,
|
|
|
|
method: 'POST',
|
|
|
|
headers: this.headers(),
|
|
|
|
responseType: 'json',
|
|
|
|
params: Object.assign({
|
|
|
|
api_version: '1.0',
|
|
|
|
api_token: this.token ? this.token : 'null',
|
|
|
|
input: '3',
|
|
|
|
method: method,
|
|
|
|
},
|
|
|
|
gatewayInput ? {gateway_input: JSON.stringify(gatewayInput)} : null
|
|
|
|
),
|
|
|
|
data: args
|
|
|
|
});
|
|
|
|
|
|
|
|
//Save SID cookie to not get token error
|
|
|
|
if (method == 'deezer.getUserData') {
|
|
|
|
let sidCookie = data.headers['set-cookie'].filter((e) => e.startsWith('sid='));
|
|
|
|
if (sidCookie.length > 0) {
|
|
|
|
sidCookie = sidCookie[0].split(';')[0];
|
|
|
|
this.sid = sidCookie.split('=')[1];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-10-31 16:54:28 +01:00
|
|
|
//Invalid CSRF
|
|
|
|
if (data.data.error && data.data.error.VALID_TOKEN_REQUIRED) {
|
|
|
|
await this.callApi('deezer.getUserData');
|
|
|
|
return await this.callApi(method, args, gatewayInput);
|
|
|
|
}
|
2020-09-07 19:12:45 +02:00
|
|
|
|
2020-08-28 23:06:19 +02:00
|
|
|
return data.data;
|
|
|
|
}
|
|
|
|
|
|
|
|
//gw_light api call using electron net
|
|
|
|
async _callApiElectronNet(method, args = {}, gatewayInput = null) {
|
|
|
|
const net = require('electron').net;
|
|
|
|
let data = await new Promise((resolve, reject) => {
|
|
|
|
//Create request
|
|
|
|
let req = net.request({
|
|
|
|
method: 'POST',
|
|
|
|
url: this.url + '?' + querystring.stringify(Object.assign({
|
|
|
|
api_version: '1.0',
|
|
|
|
api_token: this.token ? this.token : 'null',
|
|
|
|
input: '3',
|
|
|
|
method: method,
|
|
|
|
},
|
|
|
|
gatewayInput ? {gateway_input: JSON.stringify(gatewayInput)} : null
|
|
|
|
)),
|
|
|
|
});
|
|
|
|
|
|
|
|
req.on('response', (res) => {
|
|
|
|
let data = Buffer.alloc(0);
|
|
|
|
|
|
|
|
//Save SID cookie
|
|
|
|
if (method == 'deezer.getUserData') {
|
|
|
|
let sidCookie = res.headers['set-cookie'].filter((e) => e.startsWith('sid='));
|
|
|
|
if (sidCookie.length > 0) {
|
|
|
|
sidCookie = sidCookie[0].split(';')[0];
|
|
|
|
this.sid = sidCookie.split('=')[1];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//Response data
|
|
|
|
res.on('data', (buffer) => {
|
|
|
|
data = Buffer.concat([data, buffer]);
|
|
|
|
});
|
|
|
|
res.on('end', () => {
|
|
|
|
resolve(data);
|
|
|
|
})
|
|
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
|
|
reject(err);
|
|
|
|
});
|
|
|
|
|
|
|
|
//Write headers
|
|
|
|
let headers = this.headers();
|
|
|
|
for(let key of Object.keys(headers)) {
|
|
|
|
req.setHeader(key, headers[key]);
|
|
|
|
}
|
|
|
|
req.write(JSON.stringify(args));
|
|
|
|
req.end();
|
|
|
|
});
|
|
|
|
|
|
|
|
data = JSON.parse(data.toString('utf-8'));
|
2020-10-31 16:54:28 +01:00
|
|
|
|
|
|
|
//Invalid CSRF
|
|
|
|
if (data.error && data.error.VALID_TOKEN_REQUIRED) {
|
|
|
|
await this.callApi('deezer.getUserData');
|
|
|
|
return await this.callApi(method, args, gatewayInput);
|
|
|
|
}
|
|
|
|
|
2020-08-28 23:06:19 +02:00
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
//true / false if success
|
|
|
|
async authorize() {
|
|
|
|
let data = await this.callApi('deezer.getUserData');
|
|
|
|
this.token = data.results.checkForm;
|
|
|
|
this.userId = data.results.USER.USER_ID;
|
|
|
|
|
|
|
|
if (!this.userId || this.userId == 0 || !this.token) return false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-11-06 18:10:42 +01:00
|
|
|
//Wrapper because electron is piece of shit
|
2020-10-31 16:54:28 +01:00
|
|
|
async callPublicApi(path, params) {
|
2020-11-06 18:10:42 +01:00
|
|
|
if (this.electron) return await this._callPublicApiElectron(path, params);
|
|
|
|
return await this._callPublicApiAxios(path, params);
|
|
|
|
}
|
|
|
|
|
|
|
|
async _callPublicApiElectron(path, params) {
|
|
|
|
const net = require('electron').net;
|
|
|
|
let data = await new Promise((resolve, reject) => {
|
|
|
|
//Create request
|
|
|
|
let req = net.request({
|
|
|
|
method: 'GET',
|
|
|
|
url: `https://api.deezer.com/${encodeURIComponent(path)}/${encodeURIComponent(params)}`
|
|
|
|
});
|
|
|
|
|
|
|
|
req.on('response', (res) => {
|
|
|
|
let data = Buffer.alloc(0);
|
|
|
|
//Response data
|
|
|
|
res.on('data', (buffer) => {
|
|
|
|
data = Buffer.concat([data, buffer]);
|
|
|
|
});
|
|
|
|
res.on('end', () => {
|
|
|
|
resolve(data);
|
|
|
|
})
|
|
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
|
|
reject(err);
|
|
|
|
});
|
|
|
|
req.end();
|
|
|
|
});
|
|
|
|
|
|
|
|
data = JSON.parse(data.toString('utf-8'));
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
async _callPublicApiAxios(path, params) {
|
2020-10-31 16:54:28 +01:00
|
|
|
let res = await axios({
|
|
|
|
url: `https://api.deezer.com/${encodeURIComponent(path)}/${encodeURIComponent(params)}`,
|
|
|
|
responseType: 'json',
|
|
|
|
method: 'GET'
|
|
|
|
});
|
|
|
|
return res.data;
|
|
|
|
}
|
|
|
|
|
2020-08-28 23:06:19 +02:00
|
|
|
//Get track URL
|
|
|
|
static getUrl(trackId, md5origin, mediaVersion, quality = 3) {
|
|
|
|
const magic = Buffer.from([0xa4]);
|
|
|
|
let step1 = Buffer.concat([
|
|
|
|
Buffer.from(md5origin),
|
|
|
|
magic,
|
|
|
|
Buffer.from(quality.toString()),
|
|
|
|
magic,
|
|
|
|
Buffer.from(trackId),
|
|
|
|
magic,
|
|
|
|
Buffer.from(mediaVersion)
|
|
|
|
]);
|
|
|
|
//MD5
|
|
|
|
let md5sum = crypto.createHash('md5');
|
|
|
|
md5sum.update(step1);
|
|
|
|
let step1md5 = md5sum.digest('hex');
|
|
|
|
|
|
|
|
let step2 = Buffer.concat([
|
|
|
|
Buffer.from(step1md5),
|
|
|
|
magic,
|
|
|
|
step1,
|
|
|
|
magic
|
|
|
|
]);
|
|
|
|
//Padding
|
|
|
|
while(step2.length%16 > 0) {
|
|
|
|
step2 = Buffer.concat([step2, Buffer.from('.')]);
|
|
|
|
}
|
|
|
|
//AES
|
|
|
|
let aesCipher = crypto.createCipheriv('aes-128-ecb', Buffer.from('jo6aey6haid2Teih'), Buffer.from(''));
|
|
|
|
let step3 = Buffer.concat([aesCipher.update(step2, 'binary'), aesCipher.final()]).toString('hex').toLowerCase();
|
|
|
|
|
|
|
|
return `https://e-cdns-proxy-${md5origin.substring(0, 1)}.dzcdn.net/mobile/1/${step3}`;
|
|
|
|
}
|
2020-09-28 12:04:19 +02:00
|
|
|
|
2020-10-31 16:54:28 +01:00
|
|
|
|
|
|
|
async fallback(info, quality = 3) {
|
|
|
|
let qualityInfo = Track.getUrlInfo(info);
|
|
|
|
|
|
|
|
//User uploaded MP3s
|
|
|
|
if (qualityInfo.trackId.startsWith('-')) {
|
|
|
|
qualityInfo.quality = 3;
|
|
|
|
return qualityInfo;
|
|
|
|
}
|
|
|
|
|
|
|
|
//Quality fallback
|
|
|
|
let newQuality = await this.qualityFallback(qualityInfo, quality);
|
|
|
|
if (newQuality != null) {
|
|
|
|
return qualityInfo;
|
|
|
|
}
|
|
|
|
//ID Fallback
|
|
|
|
let trackData = await this.callApi('deezer.pageTrack', {sng_id: qualityInfo.trackId});
|
2020-09-28 12:04:19 +02:00
|
|
|
try {
|
2020-10-31 16:54:28 +01:00
|
|
|
if (trackData.results.DATA.FALLBACK.SNG_ID.toString() != qualityInfo.trackId) {
|
|
|
|
let newId = trackData.results.DATA.FALLBACK.SNG_ID.toString();
|
|
|
|
let newTrackData = await this.callApi('deezer.pageTrack', {sng_id: newId});
|
|
|
|
let newTrack = new Track(newTrackData.results.DATA);
|
|
|
|
return this.fallback(newTrack.streamUrl);
|
2020-09-28 12:04:19 +02:00
|
|
|
}
|
|
|
|
} catch (e) {
|
2020-11-06 18:10:42 +01:00
|
|
|
logger.warn('TrackID Fallback failed: ' + e + ' Original ID: ' + qualityInfo.trackId);
|
2020-10-31 16:54:28 +01:00
|
|
|
}
|
|
|
|
//ISRC Fallback
|
|
|
|
try {
|
|
|
|
let publicTrack = this.callPublicApi('track', 'isrc:' + trackData.results.DATA.ISRC);
|
|
|
|
let newId = publicTrack.id.toString();
|
|
|
|
let newTrackData = await this.callApi('deezer.pageTrack', {sng_id: newId});
|
|
|
|
let newTrack = new Track(newTrackData.results.DATA);
|
|
|
|
return this.fallback(newTrack.streamUrl);
|
|
|
|
} catch (e) {
|
2020-11-06 18:10:42 +01:00
|
|
|
logger.warn('ISRC Fallback failed: ' + e + ' Original ID: ' + qualityInfo.trackId);
|
2020-10-31 16:54:28 +01:00
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
//Fallback thru available qualities, -1 if none work
|
|
|
|
async qualityFallback(info, quality = 3) {
|
|
|
|
try {
|
|
|
|
let res = await axios.head(DeezerAPI.getUrl(info.trackId, info.md5origin, info.mediaVersion, quality));
|
|
|
|
if (res.status > 400) throw new Error(`Status code: ${res.status}`);
|
|
|
|
//Make sure it's an int
|
|
|
|
info.quality = parseInt(quality.toString(), 10);
|
|
|
|
info.size = parseInt(res.headers['content-length'], 10);
|
|
|
|
return info;
|
|
|
|
} catch (e) {
|
|
|
|
logger.warn('Quality fallback: ' + e);
|
2020-09-28 12:04:19 +02:00
|
|
|
//Fallback
|
|
|
|
//9 - FLAC
|
|
|
|
//3 - MP3 320
|
|
|
|
//1 - MP3 128
|
2020-10-31 16:54:28 +01:00
|
|
|
let nq = -1;
|
|
|
|
if (quality == 3) nq = 1;
|
|
|
|
if (quality == 9) nq = 3;
|
|
|
|
if (quality == 1) return null;
|
|
|
|
return this.qualityFallback(info, nq);
|
2020-09-28 12:04:19 +02:00
|
|
|
}
|
|
|
|
}
|
2020-08-28 23:06:19 +02:00
|
|
|
}
|
|
|
|
|
2020-10-31 16:54:28 +01:00
|
|
|
class DeezerStream extends Readable {
|
|
|
|
constructor(qualityInfo, options) {
|
|
|
|
super(options);
|
|
|
|
this.qualityInfo = qualityInfo;
|
|
|
|
this.ended = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async open(offset, end) {
|
|
|
|
//Prepare decryptor
|
|
|
|
this.decryptor = new DeezerDecryptionStream(this.qualityInfo.trackId, {offset});
|
|
|
|
this.decryptor.on('end', () => {
|
|
|
|
this.ended = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
//Calculate headers
|
|
|
|
let offsetBytes = offset - (offset % 2048);
|
|
|
|
end = (end == -1) ? '' : end;
|
|
|
|
let url = DeezerAPI.getUrl(this.qualityInfo.trackId, this.qualityInfo.md5origin, this.qualityInfo.mediaVersion, this.qualityInfo.quality);
|
|
|
|
|
|
|
|
//Open request
|
|
|
|
await new Promise((res) => {
|
|
|
|
this.request = https.get(url, {headers: {'Range': `bytes=${offsetBytes}-${end}`}}, (r) => {
|
|
|
|
r.pipe(this.decryptor);
|
|
|
|
this.size = parseInt(r.headers['content-length'], 10) + offsetBytes;
|
|
|
|
res();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async _read() {
|
|
|
|
//Decryptor ended
|
|
|
|
if (this.ended)
|
|
|
|
return this.push(null);
|
|
|
|
|
|
|
|
this.decryptor.once('readable', () => {
|
|
|
|
this.push(this.decryptor.read());
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
_destroy(err, callback) {
|
|
|
|
this.request.destroy();
|
|
|
|
this.decryptor.destroy();
|
|
|
|
callback();
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2020-08-28 23:06:19 +02:00
|
|
|
class DeezerDecryptionStream extends Transform {
|
|
|
|
|
|
|
|
constructor(trackId, options = {offset: 0}) {
|
|
|
|
super();
|
|
|
|
//Offset as n chunks
|
|
|
|
this.offset = Math.floor(options.offset / 2048);
|
|
|
|
//How many bytes to drop
|
|
|
|
this.drop = options.offset % 2048;
|
|
|
|
this.buffer = Buffer.alloc(0);
|
|
|
|
|
|
|
|
this.key = decryptor.getKey(trackId);
|
|
|
|
}
|
|
|
|
|
|
|
|
_transform(chunk, encoding, next) {
|
|
|
|
//Restore leftovers
|
|
|
|
chunk = Buffer.concat([this.buffer, chunk]);
|
|
|
|
|
|
|
|
while (chunk.length >= 2048) {
|
|
|
|
//Decrypt
|
|
|
|
let slice = chunk.slice(0, 2048);
|
|
|
|
if ((this.offset % 3) == 0) {
|
|
|
|
slice = decryptor.decryptBuffer(this.key, slice);
|
|
|
|
}
|
|
|
|
this.offset++;
|
|
|
|
|
|
|
|
//Cut bytes
|
|
|
|
if (this.drop > 0) {
|
|
|
|
slice = slice.slice(this.drop);
|
|
|
|
this.drop = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.push(slice);
|
|
|
|
|
|
|
|
//Replace original buffer
|
|
|
|
chunk = chunk.slice(2048);
|
|
|
|
}
|
|
|
|
//Save leftovers
|
|
|
|
this.buffer = chunk;
|
|
|
|
|
|
|
|
next();
|
|
|
|
}
|
|
|
|
|
|
|
|
//Last chunk
|
|
|
|
async _flush(cb) {
|
|
|
|
//drop should be 0, so it shouldnt affect
|
|
|
|
this.push(this.buffer.slice(this.drop));
|
|
|
|
this.drop = 0;
|
|
|
|
this.buffer = Buffer.alloc(0);
|
|
|
|
cb();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-10-31 16:54:28 +01:00
|
|
|
module.exports = {DeezerAPI, DeezerDecryptionStream, DeezerStream};
|