// ==UserScript== // @name dzunlock // @namespace io.github.uhwot.dzunlock // @description enables deezer hifi features lol // @author uh wot // @version 1.0.1 // @homepageURL https://git.freezer.life/uhwot/dzunlock // @downloadURL https://git.freezer.life/uhwot/dzunlock/raw/branch/master/dzunlock.user.js // @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== const debug = false 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 for (let i = args[0].data; i < args[0].data.length; i++) { args[0].data[i].FILESIZE_FLAC = "1" } 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); } });