diff --git a/extension/main.js b/extension/main.js index b6da3e3..59c19fe 100644 --- a/extension/main.js +++ b/extension/main.js @@ -13,10 +13,9 @@ const type2icon = { genre: '$(telescope) ', user: '$(account) ', }; - +const hhmmss = (s) => (new Date(s * 1000)).toISOString().slice(11, 19).replace(/^00:/, ''); // 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))); @@ -28,6 +27,7 @@ const fetch = (url, opt, data) => new Promise((resolve, reject) => { // deezer API wall of shame: // - not restful, so we can't infer it structure // - /track/:id gives contributors but /search/track?q= don't +// - inconsistent listing structure (/playlist/:id => tracks.data, sometimes=>data, sometimes data.tracks) const menus = { _: [ { path: 'search/track?q=', label: '$(play-circle) track search' }, @@ -60,64 +60,65 @@ const menus = { _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 { +async function browse(url_or_event, label) { + try { + 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({ title: label }); + if (!input) return; + return await browse(url.replace(/0$/, '') + input, label); + } else if (menus[id]) { // menu step + const pick = menus[id].length > 1 ? await vscode.window.showQuickPick(menus[id], { title: label || url }) : menus[id][0]; + if (!pick) return; + return await browse(url + (pick.path || pick.label), pick.label); + } else { // fetch step + const json = JSON.parse(await fetch("https://api.deezer.com" + url)); // todo: json.next? + console.debug(json); + const data = json.data?.tracks || json.data || json.tracks?.data; + const picked = url.match(/\/(playlist|album)\//); + const canPickMany = data.find(item => item.type == "track"); const choices = data.map(entry => ({ ...entry, picked, - label: (type2icon[entry.type] || '') + (entry.title_short || entry.name), - description: [entry.artist?.name,entry.title_version].join(' '), + label: (type2icon[entry.type] || '') + (entry.title_short || entry.name || entry.title), + description: [entry.artist?.name, entry.title_version, entry.nb_tracks].join(' '), path: `/${entry.type}/${entry.id}`, })); - const picks = await vscode.window.showQuickPick(choices, { title: url, canPickMany }); + const picks = await vscode.window.showQuickPick(choices, { title: label || url, canPickMany }); if (!picks) return; - return canPickMany ? picks : await browse(picks.path); - } catch (e) { console.error(e) } - } + return canPickMany ? picks : await browse(picks.path, picks.label); + } + } 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}`, +const with_url = async (songs) => songs?.length ? await vscode.window.withProgress({ title: 'Fetching Song Info...', location }, async (progress) => { + try { + const next = (val) => (progress.report({ increment: 100 / 4 }), val); + 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 - })); -}):[]; + const base = next("https://www.deezer.com/ajax/gw-light.php?input=3&api_version=1.0"); + const DZR_PNG = next(await gw('deezer.ping')); + const USR_NFO = next(await gw('deezer.getUserData', DZR_PNG.SESSION)); + const SNG_NFO = next(await gw('song.getListData', DZR_PNG.SESSION, USR_NFO.checkForm, { method: 'POST' }, JSON.stringify({ sng_ids: songs.map(s => s.id) }))); + const URL_NFO = next(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" }] }] + })))); + const errors = URL_NFO.data.map((nfo, i) => [nfo.errors, songs[i]]).filter(([err]) => err).map(([[err], sng]) => `${sng.title}: ${err.message} (${err.code})`).join('\n'); + if (errors) setTimeout(() => vscode.window.showWarningMessage(errors), 500); // can't warn while progress ? + return songs.map(({ id, title_short, title_version, artist, contributors, duration }, i) => ({ + id, title: title_short.replace(/ ?\(feat.*?\)/, ''), 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 + })).filter(sng => sng.url); + } catch (e) { console.error(e) } +}) : []; class DzrWebView { // can't Audio() in VSCode, we need a webview - statuses = ['dzr.play','dzr.show','dzr.next'].map((command) => { + 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'); @@ -127,33 +128,44 @@ class DzrWebView { // can't Audio() in VSCode, we need a webview 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); + #state = { }; + 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); + } + if (key == 'queue') this._onDidChangeTreeData.fire(); + vscode.commands.executeCommand('setContext', `dzr.${key}`, value); + this.post('state', target, [key]); + this.renderStatus(); + return true; } - vscode.commands.executeCommand('setContext', `dzr.${key}`, value); - this.post('state', target, [key]); - this.renderStatus(); - return true; - }}); + }); constructor() { this.initAckSemaphore(); - this.renderStatus(); + this.state.index = -1; + this.state.playing = false; + this.state.ready = false; + this.state.current = null; + this.state.looping = conf.get('loop'); + this.state.queue = conf.get('queue'); } renderStatus() { + const label = this.state.current ? `${this.state.current.title} - ${item.artists?.map(a => a.name).join()}` : ''; 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 + this.statuses[1].tooltip = this.state.ready ? label : "Initiate interaction first"; + this.statuses[1].text = this.state.ready ? label.length < 20 ? label : (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 + this.treeView.description = this.state.queue?.length ? `${this.state.index + 1}/${this.state.queue.length}` : 'empty'; + this.treeView.message=this.state.queue?.length ? null : "Empty Queue. Add tracks using the + button"; } async show(htmlUri) { - if(this.panel)return this.panel.reveal(vscode.ViewColumn.One); - this.panel = vscode.window.createWebviewPanel('dzr.player','Player',vscode.ViewColumn.One, { + 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, @@ -165,7 +177,7 @@ class DzrWebView { // can't Audio() in VSCode, we need a webview 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 ]); + post = (action, ...arg) => this.panel?.webview.postMessage([action, ...arg]); // event from webview player player_bufferized() { this.waitAckSemaphore(); @@ -177,10 +189,37 @@ class DzrWebView { // can't Audio() in VSCode, we need a webview user_interact() { this.state.ready = true; } error(msg) { vscode.window.showErrorMessage(msg); } badAction(action) { console.error(`unHandled action "${action}" from webview`); } + // tree + dropMimeTypes = ['application/vnd.code.tree.dzrQueue']; + dragMimeTypes = ['text/uri-list']; + _onDidChangeTreeData = new vscode.EventEmitter(); + onDidChangeTreeData = this._onDidChangeTreeData.event; + treeView = vscode.window.createTreeView('dzr.queue', { treeDataProvider: this, dragAndDropController: this, canSelectMany: true }); + + /**@returns {vscode.TreeItem} */ + getTreeItem = (item) => ({ + iconPath: vscode.ThemeIcon.File, + label: item.title, + description: item.artists.map(a => a.name).join(), + contextValue: 'dzr.track', + command: { title: 'Play', command: 'dzr.next', tooltip: 'Play', arguments: [this.state.queue.indexOf(item)] }, + tooltip: hhmmss(item.duration)//JSON.stringify(item, null, 2), + }) + getChildren = () => this.state.queue + async handleDrag(sources, treeDataTransfer, token) { + treeDataTransfer.set(this.dropMimeTypes[0], new vscode.DataTransferItem(sources)); + } + async handleDrop(onto, transfer, token) { + const sources = transfer.get(this.dropMimeTypes[0])?.value; + if (!sources || sources.includes(onto)) return; //don't move selection onto one of it members + const striped = this.state.queue.filter(item => !sources.includes(item)); + const index = this.state.queue.indexOf(onto); + this.state.queue = [...striped.slice(0, index), ...sources, ...striped.slice(index)]; + } } 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 () => { + 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]; @@ -188,21 +227,21 @@ exports.activate = async function (/**@type {vscode.ExtensionContext}*/ context) 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); + 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, + context.subscriptions.push(...dzr.statuses, dzr.treeView, 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.add", async (path) => dzr.state.queue = [...dzr.state.queue, ...await with_url(await browse(path)) || []]), + vscode.commands.registerCommand("dzr.remove", async (item, items) => (items || [item]).map(i => vscode.commands.executeCommand('dzr.removeAt', dzr.state.queue.indexOf(i)))), + vscode.commands.registerCommand("dzr.removeAt", async (index) => index >= 0 && (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]; @@ -212,24 +251,26 @@ exports.activate = async function (/**@type {vscode.ExtensionContext}*/ context) } dzr.state.queue = shuffle; }), - vscode.commands.registerCommand("dzr.next", async (pos=dzr.state.index + 1) => { + 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()}`); + item && vscode.commands.executeCommand('dzr.load', item); }), - vscode.commands.registerCommand("dzr.load", async (url,id,label) => { - dzr.state.label = label; + vscode.commands.registerCommand("dzr.load", async (item) => { + if (item.expire > new Date()/1000) { + with_url(dzr.state.queue);//TODO: hope item is now up to date + } const hex = (str) => str.split('').map(c => c.charCodeAt(0)) - const md5 = hex(crypto.createHash('md5').update(`${id}`).digest('hex')); + const md5 = hex(crypto.createHash('md5').update(`${item.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; + dzr.post('open', item); + const buf_enc = await fetch(item.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)); + const deco = ciph.update(buf_enc.subarray(pos, pos + stripe)); buf_enc.set(deco, pos); } dzr.post('append', Uint8Array.from(buf_enc)); diff --git a/extension/package.json b/extension/package.json index 14971db..d5b2498 100644 --- a/extension/package.json +++ b/extension/package.json @@ -12,6 +12,18 @@ "*" ], "contributes": { + "viewsWelcome": [ + { + "view": "explorer.dzr", + "contents": "not shown" + } + ], + "keybindings (displayed but dont work)": [ + { + "command": "dzr.remove", + "key": "delete" + } + ], "commands": [ { "category": "dzr", @@ -55,6 +67,18 @@ "title": "Queue Add", "icon": "$(add)" }, + { + "category": "dzr", + "command": "dzr.remove", + "title": "Queue Remove", + "icon": "$(close)" + }, + { + "category": "dzr", + "command": "dzr.removeAt", + "title": "Queue Remove", + "icon": "$(close)" + }, { "category": "dzr", "command": "dzr.clear", @@ -69,10 +93,34 @@ } ], "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" } + "view/title": [ + { + "when": "view == dzr.queue && dzr.queue!=''", + "group": "navigation@1", + "command": "dzr.clear" + }, + { + "when": "view == dzr.queue && dzr.queue!=''", + "group": "navigation@2", + "command": "dzr.shuffle" + }, + { + "when": "view == dzr.queue", + "group": "navigation@3", + "command": "dzr.add" + } + ], + "view/item/context": [ + { + "group": "inline", + "command": "dzr.remove", + "when": "viewItem == dzr.track" + }, + { + "group": "navigation", + "command": "dzr.remove", + "when": "viewItem == dzr.track" + } ] }, "configuration": { @@ -89,12 +137,23 @@ "default": [], "description": "Persistent track queue" }, - "dzr.loop": { + "dzr.looping": { "type": "boolean", "default": false, "description": "Queue looping" } } + }, + "views": { + "explorer": [ + { + "id": "dzr.queue", + "name": "Player Queue", + "icon": "play", + "contextualTitle": "Queue", + "whenn": "dzr.ready" + } + ] } } } \ No newline at end of file diff --git a/extension/webview.html b/extension/webview.html index 63c8151..112a260 100644 --- a/extension/webview.html +++ b/extension/webview.html @@ -1,30 +1,28 @@ - - +

Disclamer

-

Closing the player tab will stop the playback

+
    +
  • Use the Player Queue side-panel to add/remove track
  • +
  • Closing this player tab will stop the playback
  • +
-
    + + - \ No newline at end of file + \ No newline at end of file