2021-07-11 11:24:25 +00:00
|
|
|
// ==UserScript==
|
|
|
|
// @name dzunlock
|
|
|
|
// @namespace io.github.uhwot.dzunlock
|
|
|
|
// @description enables deezer hifi features lol
|
|
|
|
// @author uh wot
|
2021-07-11 16:26:04 +00:00
|
|
|
// @version 1.0.3
|
2021-07-11 16:19:38 +00:00
|
|
|
// @homepageURL https://git.freezer.life/uhwot/dzunlock
|
|
|
|
// @downloadURL https://git.freezer.life/uhwot/dzunlock/raw/branch/master/dzunlock.user.js
|
2021-07-11 11:24:25 +00:00
|
|
|
// @icon https://cdns-files.dzcdn.net/cache/images/common/favicon/favicon-96x96.852baf648e79894b668670e115e4a375.png
|
|
|
|
// @include /^https:\/\/www\.deezer\.com\/[a-z]{2}\/($|track|album|artist|playlist|episode|show|profile|channels)/
|
|
|
|
// @match https://www.deezer.com/search/*
|
|
|
|
// @match https://www.deezer.com/account/*
|
|
|
|
// @require https://cdnjs.cloudflare.com/ajax/libs/aes-js/3.1.2/index.min.js
|
|
|
|
// @require https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.min.js
|
|
|
|
// @grant GM_getValue
|
|
|
|
// @grant GM_setValue
|
|
|
|
// ==/UserScript==
|
|
|
|
|
2021-07-11 16:26:04 +00:00
|
|
|
const debug = false
|
2021-07-11 11:24:25 +00:00
|
|
|
|
|
|
|
function log(...args) {
|
|
|
|
if (debug) {
|
|
|
|
return console.log(...args)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// https://github.com/werk85/fetch-intercept/blob/develop/src/attach.js modified for browser support
|
|
|
|
let interceptors = [];
|
|
|
|
|
|
|
|
function interceptor(fetch, ...args) {
|
|
|
|
const reversedInterceptors = interceptors.reduce((array, interceptor) => [interceptor].concat(array), []);
|
|
|
|
let promise = Promise.resolve(args);
|
|
|
|
|
|
|
|
// Register request interceptors
|
|
|
|
reversedInterceptors.forEach(({ request, requestError }) => {
|
|
|
|
if (request || requestError) {
|
|
|
|
promise = promise.then(args => request(...args), requestError);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Register fetch call
|
|
|
|
promise = promise.then(args => {
|
|
|
|
const request = new Request(...args);
|
|
|
|
return fetch(request).then(response => {
|
|
|
|
response.request = request;
|
|
|
|
return response;
|
|
|
|
}).catch(error => {
|
|
|
|
error.request = request;
|
|
|
|
return Promise.reject(error);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Register response interceptors
|
|
|
|
reversedInterceptors.forEach(({ response, responseError }) => {
|
|
|
|
if (response || responseError) {
|
|
|
|
promise = promise.then(response, responseError);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
|
|
|
unsafeWindow.fetch = (function (fetch) {
|
|
|
|
return function (...args) {
|
|
|
|
return interceptor(fetch, ...args);
|
|
|
|
};
|
|
|
|
})(unsafeWindow.fetch);
|
|
|
|
|
|
|
|
fetchIntercept = {
|
|
|
|
register: function (interceptor) {
|
|
|
|
interceptors.push(interceptor);
|
|
|
|
return () => {
|
|
|
|
const index = interceptors.indexOf(interceptor);
|
|
|
|
if (index >= 0) {
|
|
|
|
interceptors.splice(index, 1);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
},
|
|
|
|
clear: function () {
|
|
|
|
interceptors = [];
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// main code starts here
|
|
|
|
|
|
|
|
const clientId = '119915'
|
|
|
|
const clientSecret = '2f5b4c9785ddc367975b83d90dc46f5c'
|
|
|
|
const playerTokenKey = [102, 228, 95, 242, 215, 50, 122, 26, 57, 216, 206, 38, 164, 237, 200, 85]
|
|
|
|
const trackCDNKey = [106, 111, 54, 97, 101, 121, 54, 104, 97, 105, 100, 50, 84, 101, 105, 104]
|
|
|
|
|
|
|
|
const qualityToFormat = {
|
|
|
|
'standard': '128',
|
|
|
|
'high': '320',
|
|
|
|
'lossless': 'flac'
|
|
|
|
}
|
|
|
|
|
|
|
|
const formatToNum = {
|
|
|
|
'128': 1,
|
|
|
|
'320': 3,
|
|
|
|
'flac': 9
|
|
|
|
}
|
|
|
|
|
|
|
|
let dataTemplate = {
|
|
|
|
data: [
|
|
|
|
{
|
|
|
|
media: [
|
|
|
|
{
|
|
|
|
cipher: { type: "BF_CBC_STRIPE" },
|
|
|
|
exp: 694208008135,
|
|
|
|
format: "",
|
|
|
|
media_type: "FULL",
|
|
|
|
nbf: 694208008135,
|
|
|
|
sources: [
|
|
|
|
{
|
|
|
|
provider: "ak",
|
|
|
|
url: "",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
provider: "ec",
|
|
|
|
url: "",
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
function str2bin(str) {
|
|
|
|
return Array.from(str).map(function (item) {
|
|
|
|
return item.charCodeAt(0);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function decryptHex(hex, key) {
|
|
|
|
hex = aesjs.utils.hex.toBytes(hex)
|
|
|
|
let aesEcb = new aesjs.ModeOfOperation.ecb(key)
|
|
|
|
return aesjs.utils.utf8.fromBytes(aesEcb.decrypt(hex)).replace(/\0+$/, '') // removes zero-padding
|
|
|
|
}
|
|
|
|
|
|
|
|
function encryptHex(str, key) {
|
|
|
|
// zero-padding
|
|
|
|
if (str.length % 16) {
|
|
|
|
str += '\x00'.repeat(16 - str.length % 16)
|
|
|
|
}
|
|
|
|
|
|
|
|
let aesEcb = new aesjs.ModeOfOperation.ecb(key)
|
|
|
|
return aesjs.utils.hex.fromBytes(aesEcb.encrypt(str2bin(str)))
|
|
|
|
}
|
|
|
|
|
|
|
|
function playerTokenPatch(playerToken) {
|
|
|
|
playerToken = JSON.parse(decryptHex(playerToken, playerTokenKey))
|
|
|
|
|
|
|
|
// enables 320/flac quality selection
|
|
|
|
playerToken.audio_qualities.wifi_streaming = ['low', 'standard', 'high', 'lossless']
|
|
|
|
// disables previews
|
|
|
|
playerToken.streaming = true
|
|
|
|
|
|
|
|
log(playerToken)
|
|
|
|
|
|
|
|
return encryptHex(JSON.stringify(playerToken), playerTokenKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
function trackCDNGen(md5_origin, format, id, media_version) {
|
|
|
|
result = [md5_origin, format, id, media_version].join('\xa4')
|
|
|
|
log(result)
|
|
|
|
|
|
|
|
result = SparkMD5.hashBinary(result) + '\xa4' + result + '\xa4'
|
|
|
|
log(result)
|
|
|
|
return encryptHex(result, trackCDNKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
async function renewAccessToken() {
|
|
|
|
let url = `https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}&output=json`
|
|
|
|
let resp = await fetch(url)
|
|
|
|
let json = await resp.json()
|
|
|
|
access_token = json['access_token']
|
|
|
|
|
|
|
|
// cache access token
|
|
|
|
GM_setValue('access_token', access_token)
|
|
|
|
GM_setValue('access_token_expiry', Math.floor(Date.now() / 1000) + Number(json['expires']))
|
|
|
|
return access_token
|
|
|
|
}
|
|
|
|
|
|
|
|
unsafeWindow.dzPlayer.setTrackList = (function (old) {
|
|
|
|
return function (...args) {
|
|
|
|
// needed for player to accept flac url responses
|
2021-07-11 16:25:17 +00:00
|
|
|
for (let i = 0; i < args[0].data.length; i++) {
|
2021-07-11 11:24:25 +00:00
|
|
|
args[0].data[i].FILESIZE_FLAC = "1"
|
2021-07-11 16:14:44 +00:00
|
|
|
}
|
2021-07-11 11:24:25 +00:00
|
|
|
|
|
|
|
log(args)
|
|
|
|
|
|
|
|
return old(...args)
|
|
|
|
};
|
|
|
|
})(unsafeWindow.dzPlayer.setTrackList);
|
|
|
|
|
|
|
|
fetchIntercept.register({
|
|
|
|
request: function (url, config) {
|
|
|
|
// Modify the url or config here
|
|
|
|
|
|
|
|
return [url, config];
|
|
|
|
},
|
|
|
|
|
|
|
|
requestError: function (error) {
|
|
|
|
// Called when an error occured during another 'request' interceptor call
|
|
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
},
|
|
|
|
|
|
|
|
response: async function (response) {
|
|
|
|
// Modify the response object
|
|
|
|
|
|
|
|
if (response.url.startsWith('https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData')) {
|
|
|
|
const json = await response.json()
|
|
|
|
|
|
|
|
// removes upgrade popup stuff
|
|
|
|
json.results.USER.ENTRYPOINTS = {}
|
|
|
|
// needed to play premium-restricted albums like https://www.deezer.com/album/801279
|
|
|
|
json.results.OFFER_ID = 600
|
|
|
|
// disables ads
|
|
|
|
json.results.USER.OPTIONS.ads_display = false
|
|
|
|
json.results.USER.OPTIONS.ads_audio = false
|
|
|
|
|
|
|
|
json.results.PLAYER_TOKEN = playerTokenPatch(json.results.PLAYER_TOKEN)
|
|
|
|
|
|
|
|
log(json)
|
|
|
|
|
|
|
|
return new Response(JSON.stringify(json))
|
|
|
|
}
|
|
|
|
|
|
|
|
if (response.url.startsWith('https://www.deezer.com/ajax/gw-light.php?method=log.listen')) {
|
|
|
|
const json = await response.json()
|
|
|
|
|
|
|
|
if (typeof json.results === 'string') {
|
|
|
|
json.results = playerTokenPatch(json.results)
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Response(JSON.stringify(json))
|
|
|
|
}
|
|
|
|
|
|
|
|
if (response.url === 'https://media.deezer.com/v1/get_url') {
|
|
|
|
const id = Number(unsafeWindow.dzPlayer.getSongId())
|
|
|
|
const quality = unsafeWindow.dzPlayer.control.getAudioQuality()
|
|
|
|
log(id, quality)
|
|
|
|
|
|
|
|
// check if account is allowed to get quality, if so use the server's response instead
|
|
|
|
const orig = await response.json()
|
|
|
|
if (id < 0 /* user-upped track */ || (unsafeWindow.dzPlayer.user_status.audio_qualities.wifi_download.includes(quality) && orig.data && orig.data[0].media && orig.data[0].media[0])) {
|
|
|
|
return new Response(JSON.stringify(orig))
|
|
|
|
}
|
|
|
|
|
|
|
|
let access_token = GM_getValue('access_token', false)
|
|
|
|
const expiry = GM_getValue('access_token_expiry', 0)
|
|
|
|
if (!access_token || Math.floor(Date.now() / 1000) >= expiry) {
|
|
|
|
access_token = await renewAccessToken()
|
|
|
|
}
|
|
|
|
|
|
|
|
url = `https://api.deezer.com/track/${id}?access_token=${access_token}`
|
|
|
|
resp = await fetch(url)
|
|
|
|
let json = await resp.json()
|
|
|
|
|
|
|
|
// renew access token if token is invalid
|
|
|
|
if (json.error && json.error.code === 300) {
|
|
|
|
access_token = await renewAccessToken()
|
|
|
|
url = `https://api.deezer.com/track/${id}?access_token=${access_token}`
|
|
|
|
resp = await fetch(url)
|
|
|
|
json = await resp.json()
|
|
|
|
}
|
|
|
|
|
|
|
|
const md5_origin = json['md5_origin']
|
|
|
|
const media_version = json['media_version']
|
|
|
|
|
|
|
|
const formats = Object.values(qualityToFormat)
|
|
|
|
let format = qualityToFormat[quality]
|
|
|
|
while (format !== '128') {
|
|
|
|
if (json['filesize_' + format] !== "0") {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
format = formats[formats.indexOf(format) - 1]
|
|
|
|
}
|
|
|
|
format_num = formatToNum[format]
|
|
|
|
|
|
|
|
const trackCDNPath = trackCDNGen(md5_origin, format_num, id, media_version)
|
|
|
|
|
|
|
|
if (format === 'flac') {
|
|
|
|
dataTemplate.data[0].media[0].format = 'FLAC'
|
|
|
|
} else {
|
|
|
|
dataTemplate.data[0].media[0].format = 'MP3_128' // doesn't matter whether it's 128 or 320
|
|
|
|
}
|
|
|
|
|
|
|
|
dataTemplate.data[0].media[0].sources[0].url = `https://cdns-proxy-${md5_origin[0]}.dzcdn.net/mobile/1/${trackCDNPath}`
|
|
|
|
dataTemplate.data[0].media[0].sources[1].url = `https://e-cdns-proxy-${md5_origin[0]}.dzcdn.net/mobile/1/${trackCDNPath}`
|
|
|
|
|
|
|
|
log(dataTemplate)
|
|
|
|
|
|
|
|
return new Response(JSON.stringify(dataTemplate))
|
|
|
|
}
|
|
|
|
|
|
|
|
return response;
|
|
|
|
},
|
|
|
|
|
|
|
|
responseError: function (error) {
|
|
|
|
// Handle an fetch error
|
|
|
|
|
|
|
|
return Promise.reject(error);
|
|
|
|
}
|
|
|
|
});
|