-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Working: - API browsing - bf-cbc key extraction - track decryption - track playback - player state double-binding (playing/queue/position) TODO: - fetch streaming (currently all fetched)
- Loading branch information
Showing
3 changed files
with
430 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,240 @@ | ||
/**@type import('vscode') */ | ||
const vscode = require("vscode"); | ||
const crypto = require('crypto'); | ||
const https = require('https'); | ||
const conf = vscode.workspace.getConfiguration("dzr"); | ||
const location = vscode.ProgressLocation.Notification; | ||
const type2icon = { | ||
track: '$(play-circle) ', | ||
artist: '$(person) ', | ||
album: '$(issues) ', | ||
playlist: '$(list-unordered)', | ||
radio: '$(broadcast) ', | ||
genre: '$(telescope) ', | ||
user: '$(account) ', | ||
}; | ||
|
||
// still no fetch() in 2023 ? | ||
const fetch = (url, opt, data) => new Promise((resolve, reject) => { | ||
//console.debug(url, opt, data); | ||
const chunks = [], req = https.request(url, opt, res => { | ||
res.on('data', chunk => chunks.push(chunk)); | ||
res.on('end', () => resolve(Buffer.concat(chunks))); | ||
}).on('error', reject); | ||
if (data) req.write(data); | ||
req.end(); | ||
}); | ||
|
||
// deezer API wall of shame: | ||
// - not restful, so we can't infer it structure | ||
// - /track/:id gives contributors but /search/track?q= don't | ||
const menus = { | ||
_: [ | ||
{ path: 'search/track?q=', label: '$(play-circle) track search' }, | ||
{ path: 'search/artist?q=', label: '$(person) artist search' }, | ||
{ path: 'search/album?q=', label: '$(issues) album search' }, | ||
{ path: 'search/playlist?q=', label: '$(list-unordered) playlist search' }, | ||
{ path: 'search/user?q=', label: '$(account) user search' }, | ||
{ path: 'search/radio?q=', label: '$(broadcast) radio search' }, | ||
{ path: 'genre', label: '$(telescope) explore' }, | ||
{ path: 'radio', label: '$(broadcast) radios list' }, | ||
{ path: 'user/0', label: '$(account) user id' }, | ||
], | ||
_artist_0: [ | ||
{ path: '/top?limit=50', label: '$(play-circle) Top Tracks' }, | ||
{ path: '/albums', label: '$(issues) Albums' }, | ||
{ path: '/related', label: '$(person) Similar Artists' }, | ||
{ path: '/radio', label: '$(broadcast) Flow' }, | ||
{ path: '/playlists', label: '$(list-unordered) Playlists' } | ||
], | ||
_user_0: [ | ||
{ path: '/playlists', label: '$(list-unordered) Playlists' }, | ||
{ path: '/tracks', label: '$(play-circle) Favorite Tracks' }, | ||
{ path: '/albums', label: '$(issues) Favorite Albums' }, | ||
{ path: '/artists', label: '$(person) Favorite Artists' }, | ||
{ path: '/flow', label: '$(broadcast) Flow' }, | ||
{ path: '/charts', label: '$(play-circle) Charts' }, | ||
], | ||
_genre_0: [{ label: '/radios' }, { label: '/artists' }], | ||
_radio_0: [{ label: '/tracks' }], | ||
_album_0: [{ label: '/tracks' }], | ||
} | ||
// browse can be : user query / list(static) / list(fetch) | ||
async function browse(url_or_event) { // sometimes | ||
const url = typeof(url_or_event) == "string" ? url_or_event : '/'; | ||
const id = url.replace(/\d+/g, '0').replace(/[^\w]/g, '_'); | ||
if (url.endsWith('=') || url.endsWith('/0')) { // query step | ||
const input = await vscode.window.showInputBox(); | ||
if (!input) return; | ||
return await browse(url.replace(/0$/, '') + input); | ||
} else if (menus[id]) { // menu step | ||
const pick = menus[id].length > 1 ? await vscode.window.showQuickPick(menus[id], { title: url }) : menus[id][0]; | ||
if (!pick) return; | ||
return await browse(url + (pick.path || pick.label)); | ||
} else { // fetch step | ||
const json = JSON.parse(await fetch("https://api.deezer.com" + url)); // todo: json.next? | ||
const data = json.data?.tracks || json.data || []; // type==playlist use deeper list | ||
const picked = url.match(/\/(playlist|album)\//); | ||
const canPickMany = data.find(item => item.type == "track"); | ||
try { | ||
const choices = data.map(entry => ({ | ||
...entry, picked, | ||
label: (type2icon[entry.type] || '') + (entry.title_short || entry.name), | ||
description: [entry.artist?.name,entry.title_version].join(' '), | ||
path: `/${entry.type}/${entry.id}`, | ||
})); | ||
const picks = await vscode.window.showQuickPick(choices, { title: url, canPickMany }); | ||
if (!picks) return; | ||
return canPickMany ? picks : await browse(picks.path); | ||
} catch (e) { console.error(e) } | ||
} | ||
} | ||
|
||
const with_url = async (songs) => songs?.length ? await vscode.window.withProgress({title: 'Fetching Song Info...', location }, async (progress) => { | ||
const base = "https://www.deezer.com/ajax/gw-light.php?input=3&api_version=1.0"; | ||
const increment = 100/4; | ||
const gw = async(method, sid, api_token = "", opt = {}, data) => JSON.parse(await fetch(`${base}&method=${method}&api_token=${api_token}`, | ||
{ ...opt, headers: { Cookie: `sid=${sid}`, ...opt?.headers } }, data)).results; | ||
progress.report({increment}) | ||
const DZR_PNG = await gw('deezer.ping'); | ||
progress.report({increment}) | ||
const USR_NFO = await gw('deezer.getUserData', DZR_PNG.SESSION); | ||
progress.report({increment}) | ||
const SNG_NFO = await gw('song.getListData', DZR_PNG.SESSION, USR_NFO.checkForm, { method: 'POST' }, JSON.stringify({ sng_ids: songs.map(s => s.id) })); | ||
progress.report({increment}) | ||
const URL_NFO = JSON.parse(await fetch('https://media.deezer.com/v1/get_url', { method: 'POST' }, JSON.stringify({ | ||
track_tokens: SNG_NFO.data.map(d => d.TRACK_TOKEN), | ||
license_token: USR_NFO.USER.OPTIONS.license_token, | ||
media: [{ type: "FULL", formats: [{ cipher: "BF_CBC_STRIPE", format: "MP3_128" }] }] | ||
}))); | ||
return songs.map(({id,title_short,title_version,artist,contributors,duration},i) => ({ | ||
id,title:title_short,version:title_version,duration, | ||
artists:(contributors||[artist])?.map(({id,name})=>({id,name})), | ||
size:+SNG_NFO.data[i].FILESIZE, | ||
expire:SNG_NFO.data[i].TRACK_TOKEN_EXPIRE, | ||
url: URL_NFO.data[i].media[0].sources[0].url | ||
})); | ||
}):[]; | ||
|
||
class DzrWebView { // can't Audio() in VSCode, we need a webview | ||
statuses = ['dzr.play','dzr.show','dzr.next'].map((command) => { | ||
const item = vscode.window.createStatusBarItem(command, vscode.StatusBarAlignment.Left, 10000); | ||
item.color = new vscode.ThemeColor('statusBarItem.prominentBackground'); | ||
item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); | ||
item.command = command; | ||
item.text = command; | ||
item.show(); | ||
return item; | ||
}); | ||
panel = null; | ||
#state = {index:-1,playing:false,ready:false,label:"artist - song",looping:conf.get('loop'),queue:conf.get('queue')}; | ||
state = new Proxy(this.#state, {set: (target, key, value) => { | ||
target[key] = value; | ||
if (['queue', 'looping'].includes(key)) { // persist those values across reboot | ||
conf.update(key, value, vscode.ConfigurationTarget.Global); | ||
} | ||
vscode.commands.executeCommand('setContext', `dzr.${key}`, value); | ||
this.post('state', target, [key]); | ||
this.renderStatus(); | ||
return true; | ||
}}); | ||
|
||
constructor() { | ||
this.initAckSemaphore(); | ||
this.renderStatus(); | ||
} | ||
|
||
renderStatus() { | ||
this.statuses[0].command = this.state.playing ? 'dzr.pause' : 'dzr.play'; | ||
this.statuses[0].text = this.state.ready && (this.state.playing ? "$(debug-pause)" : "$(play)"); | ||
this.statuses[1].tooltip = this.state.ready ? this.state.label : "Initiate interaction first"; | ||
this.statuses[1].text = this.state.ready ? this.state.label.length<20?this.state.label:(this.state.label.slice(0,20)+'…') : "$(play)" | ||
this.statuses[2].text = this.state.ready && this.state.queue.length ? `${this.state.index+1}/${this.state.queue.length} $(chevron-right)`:null;//debug-step-over | ||
} | ||
async show(htmlUri) { | ||
if(this.panel)return this.panel.reveal(vscode.ViewColumn.One); | ||
this.panel = vscode.window.createWebviewPanel('dzr.player','Player',vscode.ViewColumn.One, { | ||
enableScripts: true, | ||
enableCommandUris: true, | ||
enableFindWidget: true, | ||
retainContextWhenHidden: true | ||
}); | ||
this.panel.webview.html = (await vscode.workspace.fs.readFile(htmlUri)).toString(); | ||
this.panel.webview.onDidReceiveMessage((action, ...args) => this[action] ? this[action](...args) : this.badAction(action)); | ||
this.panel.onDidDispose(() => this.state.ready = this.panel = null); | ||
this.post('state', this.state, Object.keys(this.state)); | ||
} | ||
initAckSemaphore() { this.postAck = new Promise((then) => this.waitAckSemaphore = then); } | ||
post = (action, ...arg) => this.panel?.webview.postMessage([action, ...arg ]); | ||
// event from webview player | ||
player_bufferized() { | ||
this.waitAckSemaphore(); | ||
this.initAckSemaphore(); | ||
} | ||
player_playing() { this.state.ready = this.state.playing = true; } | ||
player_pause() { this.state.playing = false; } | ||
player_ended() { vscode.commands.executeCommand('dzr.next'); } | ||
user_interact() { this.state.ready = true; } | ||
error(msg) { vscode.window.showErrorMessage(msg); } | ||
badAction(action) { console.error(`unHandled action "${action}" from webview`); } | ||
} | ||
exports.activate = async function (/**@type {vscode.ExtensionContext}*/ context) { | ||
// deezer didn't DMCA'd dzr so let's follow the same path here | ||
conf.get('cbc') || vscode.window.withProgress({title: 'Extracting CBC key...', location}, async () => { | ||
const html_url = 'https://www.deezer.com/en/channels/explore'; | ||
const html = (await fetch(html_url)).toString('utf-8'); | ||
const js_url = html.match(/src="(http[^"]+app-web\.[^"]+\.js)"/)?.[1]; | ||
if (!js_url) return await vscode.window.showErrorMessage('CBC Extract: No JS WebApp found'); | ||
const keys = (await fetch(js_url)).toString('utf-8').match(/%5B0x..%2C.{39}%2C0x..%5D/g); | ||
const [a, b] = keys.map(part => part.slice(3, -3).split('%2C').map(i => String.fromCharCode(parseInt(i))).reverse()); | ||
const cbc = a.map((a, i) => `${a}${b[i]}`).join('');// zip a+b | ||
const sha = crypto.createHash('sha1').update(cbc).digest('hex').slice(0,8); | ||
if (sha != '3ad58d92') return await vscode.window.showErrorMessage('Bad extracted key'); | ||
conf.update('cbc', cbc, vscode.ConfigurationTarget.Global); | ||
}); | ||
const dzr = new DzrWebView(); | ||
const htmlUri = vscode.Uri.joinPath(context.extensionUri, 'webview.html'); | ||
context.subscriptions.push( | ||
...dzr.statuses, | ||
vscode.commands.registerCommand('dzr.show', () => dzr.show(htmlUri)), | ||
vscode.commands.registerCommand("dzr.play", () => dzr.post('play')), | ||
vscode.commands.registerCommand("dzr.pause", () => dzr.post('pause')), | ||
vscode.commands.registerCommand("dzr.loopAll", () => dzr.looping = true), | ||
vscode.commands.registerCommand("dzr.loopOff", () => dzr.looping = false), | ||
vscode.commands.registerCommand("dzr.add", async (path) => dzr.state.queue = [...dzr.state.queue, ...await with_url(await browse(path))||[]]), | ||
vscode.commands.registerCommand("dzr.pop", async (index) => dzr.state.queue = [...dzr.state.queue.slice(0,index),...dzr.state.queue.slice(index+1)]), | ||
vscode.commands.registerCommand("dzr.clear", async () => dzr.state.queue = []), | ||
vscode.commands.registerCommand("dzr.shuffle", async () => { | ||
const shuffle = [...dzr.state.queue]; | ||
for (let i = shuffle.length - 1; i > 0; i--) { | ||
const j = Math.floor(Math.random() * (i + 1)); | ||
[shuffle[i], shuffle[j]] = [shuffle[j], shuffle[i]]; | ||
} | ||
dzr.state.queue = shuffle; | ||
}), | ||
vscode.commands.registerCommand("dzr.next", async (pos=dzr.state.index + 1) => { | ||
dzr.state.index = (pos >= dzr.state.queue.length) ? 0 : pos; | ||
const item = dzr.state.queue[dzr.state.index]; | ||
item && vscode.commands.executeCommand('dzr.load', item.url, item.id, `${item.title} - ${item.artists?.map(a=>a.name).join()}`); | ||
}), | ||
vscode.commands.registerCommand("dzr.load", async (url,id,label) => { | ||
dzr.state.label = label; | ||
const hex = (str) => str.split('').map(c => c.charCodeAt(0)) | ||
const md5 = hex(crypto.createHash('md5').update(`${id}`).digest('hex')); | ||
const key = Buffer.from(hex(conf.get('cbc')).map((c, i) => c ^ md5[i] ^ md5[i + 16])); | ||
const iv = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]); | ||
const stripe = 2048;//TODO:use .pipe() API https://codereview.stackexchange.com/questions/57492/ | ||
dzr.post('open', {id}); | ||
const buf_enc = await fetch(url); | ||
for(let pos = 0; pos < buf_enc.length ; pos+=stripe) { | ||
if((pos>>11)%3)continue; | ||
const ciph = crypto.createDecipheriv('bf-cbc', key, iv).setAutoPadding(false) | ||
const deco = ciph.update(buf_enc.subarray(pos, pos+stripe)); | ||
buf_enc.set(deco, pos); | ||
} | ||
dzr.post('append', Uint8Array.from(buf_enc)); | ||
await dzr.postAck; | ||
dzr.post('close'); | ||
}), | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
{ | ||
"name": "dzr", | ||
"displayName": "DZR player", | ||
"description": "deezer.com player", | ||
"version": "0.0.1", | ||
"publisher": "yne", | ||
"engines": { | ||
"vscode": "^1.73.0" | ||
}, | ||
"main": "./main.js", | ||
"activationEvents": [ | ||
"*" | ||
], | ||
"contributes": { | ||
"commands": [ | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.show", | ||
"title": "Show Player Tab", | ||
"icon": "$(eye)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.play", | ||
"title": "Play", | ||
"icon": "$(debug-run)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.pause", | ||
"title": "Pause", | ||
"icon": "$(debug-pause)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.next", | ||
"title": "Play next", | ||
"icon": "$(chevron-right)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.loopAll", | ||
"title": "Loop all Enable", | ||
"icon": "$(sync)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.loopOff", | ||
"title": "Loop all Disable", | ||
"icon": "$(sync-ignored)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.add", | ||
"title": "Queue Add", | ||
"icon": "$(add)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.clear", | ||
"title": "Queue Clear", | ||
"icon": "$(clear-all)" | ||
}, | ||
{ | ||
"category": "dzr", | ||
"command": "dzr.shuffle", | ||
"title": "Queue Shuffle", | ||
"icon": "$(arrow-swap)" | ||
} | ||
], | ||
"menus": { | ||
"editor/title": [ | ||
{"group": "navigation", "when": "dzr.ready && !dzr.playing", "command": "dzr.play" }, | ||
{"group": "navigation", "when": "dzr.ready && dzr.playing", "command": "dzr.pause" }, | ||
{"group": "navigation", "command": "dzr.add" } | ||
] | ||
}, | ||
"configuration": { | ||
"type": "object", | ||
"title": "dzr configuration", | ||
"properties": { | ||
"dzr.cbc": { | ||
"type": "string", | ||
"default": "", | ||
"description": "track decryption key" | ||
}, | ||
"dzr.queue": { | ||
"type": "array", | ||
"default": [], | ||
"description": "Persistent track queue" | ||
}, | ||
"dzr.loop": { | ||
"type": "boolean", | ||
"default": false, | ||
"description": "Queue looping" | ||
} | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.