From d4daf7231c1959d4b319d90271f3edba7aba1d3c Mon Sep 17 00:00:00 2001 From: Araxeus <78568641+Araxeus@users.noreply.github.com> Date: Thu, 25 Mar 2021 01:36:22 +0200 Subject: [PATCH] merge source (#3) * Added Discord timeout * Add getOptions in plugin util * Mutex in ffmpeg conversion (only supports one command at a time) * Add menu customization in plugin system * Add ytpl package (playlist info) * Handle ffmpeg metadata flags when metadata is not present * Only use artist in file name if present * Export sendError method * Handle image not present in metadata util * Add downloader utils (getFolder and default menu label) * Pass (optional) existing metadata and subfolder in mp3 converter * Add listener to download playlist * Add custom menu in downloader plugin ("download playlist" item) * nit: fix main CSS style * Only set the "enable" item in menu if plugin not enabled * Navigation plugin: inject HTML once CSS is loaded Co-authored-by: Sem Visscher Co-authored-by: TC --- config/defaults.js | 4 ++ config/plugins.js | 5 ++ menu.js | 48 ++++++++++---- package.json | 4 +- plugins/discord/back.js | 10 ++- plugins/downloader/back.js | 25 ++++---- plugins/downloader/menu.js | 63 +++++++++++++++++++ plugins/downloader/utils.js | 4 ++ plugins/downloader/youtube-dl.js | 87 +++++++++++++++++++------- plugins/navigation/back.js | 5 +- plugins/navigation/front.js | 20 +++--- plugins/navigation/templates/back.html | 2 +- plugins/utils.js | 9 ++- yarn.lock | 21 ++++++- youtube-music.css | 2 +- 15 files changed, 248 insertions(+), 61 deletions(-) create mode 100644 plugins/downloader/menu.js create mode 100644 plugins/downloader/utils.js diff --git a/config/defaults.js b/config/defaults.js index 6238e114..855b1964 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -35,6 +35,10 @@ const defaultConfig = { ffmpegArgs: [], // e.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s downloadFolder: undefined, // Custom download folder (absolute path) }, + discord: { + activityTimoutEnabled: true, // if enabled, the discord rich presence gets cleared when music paused after the time specified below + activityTimoutTime: 10 * 60 * 1000 // 10 minutes + }, }, }; diff --git a/config/plugins.js b/config/plugins.js index 03962c3d..7a73335c 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -24,6 +24,10 @@ function setOptions(plugin, options) { }); } +function getOptions(plugin) { + return store.get("plugins")[plugin]; +} + function enable(plugin) { setOptions(plugin, { enabled: true }); } @@ -38,4 +42,5 @@ module.exports = { enable, disable, setOptions, + getOptions, }; diff --git a/menu.js b/menu.js index 661929ca..91bb073d 100644 --- a/menu.js +++ b/menu.js @@ -1,26 +1,50 @@ +const { existsSync } = require("fs"); +const path = require("path"); + const { app, Menu } = require("electron"); const is = require("electron-is"); const { getAllPlugins } = require("./plugins/utils"); const config = require("./config"); +const pluginEnabledMenu = (plugin, label = "") => ({ + label: label || plugin, + type: "checkbox", + checked: config.plugins.isEnabled(plugin), + click: (item) => { + if (item.checked) { + config.plugins.enable(plugin); + } else { + config.plugins.disable(plugin); + } + }, +}); + const mainMenuTemplate = (win) => [ { label: "Plugins", submenu: [ ...getAllPlugins().map((plugin) => { - return { - label: plugin, - type: "checkbox", - checked: config.plugins.isEnabled(plugin), - click: (item) => { - if (item.checked) { - config.plugins.enable(plugin); - } else { - config.plugins.disable(plugin); - } - }, - }; + const pluginPath = path.join(__dirname, "plugins", plugin, "menu.js"); + + if (!config.plugins.isEnabled(plugin)) { + return pluginEnabledMenu(plugin); + } + + if (existsSync(pluginPath)) { + const getPluginMenu = require(pluginPath); + return { + label: plugin, + submenu: [ + pluginEnabledMenu(plugin, "Enabled"), + ...getPluginMenu(win, config.plugins.getOptions(plugin), () => + module.exports.setApplicationMenu(win) + ), + ], + }; + } + + return pluginEnabledMenu(plugin); }), { type: "separator" }, { diff --git a/package.json b/package.json index 94eab900..bfb89748 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@ffmpeg/core": "^0.8.5", "@ffmpeg/ffmpeg": "^0.9.7", "YoutubeNonStop": "git://github.com/lawfx/YoutubeNonStop.git#v0.8.1", + "async-mutex": "^0.3.1", "browser-id3-writer": "^4.4.0", "custom-electron-titlebar": "^3.2.6", "discord-rpc": "^3.2.0", @@ -78,7 +79,8 @@ "electron-updater": "^4.3.6", "filenamify": "^4.2.0", "node-fetch": "^2.6.1", - "ytdl-core": "^4.4.5" + "ytdl-core": "^4.4.5", + "ytpl": "^2.0.5" }, "devDependencies": { "electron": "^11.2.3", diff --git a/plugins/discord/back.js b/plugins/discord/back.js index 3e4ab629..0ebcc624 100644 --- a/plugins/discord/back.js +++ b/plugins/discord/back.js @@ -9,7 +9,9 @@ const rpc = new Discord.Client({ // Application ID registered by @semvis123 const clientId = "790655993809338398"; -module.exports = (win) => { +let clearActivity; + +module.exports = (win, {activityTimoutEnabled, activityTimoutTime}) => { const registerCallback = getSongInfo(win); // If the page is ready, register the callback @@ -29,7 +31,13 @@ module.exports = (win) => { // Add an idle icon to show that the song is paused activityInfo.smallImageKey = "idle"; activityInfo.smallImageText = "idle/paused"; + // Set start the timer so the activity gets cleared after a while if enabled + if (activityTimoutEnabled) + clearActivity = setTimeout(()=>rpc.clearActivity(), activityTimoutTime||10,000); + } else { + // stop the clear activity timout + clearTimeout(clearActivity); // Add the start and end time of the song const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000; activityInfo.startTimestamp = songStartTime; diff --git a/plugins/downloader/back.js b/plugins/downloader/back.js index a5a49958..a1d2ff80 100644 --- a/plugins/downloader/back.js +++ b/plugins/downloader/back.js @@ -45,19 +45,21 @@ function handle(win) { let fileBuffer = songBuffer; try { - const coverBuffer = metadata.image.toPNG(); const writer = new ID3Writer(songBuffer); + if (metadata.image) { + const coverBuffer = metadata.image.toPNG(); - // Create the metadata tags - writer - .setFrame("TIT2", metadata.title) - .setFrame("TPE1", [metadata.artist]) - .setFrame("APIC", { - type: 3, - data: coverBuffer, - description: "", - }); - writer.addTag(); + // Create the metadata tags + writer + .setFrame("TIT2", metadata.title) + .setFrame("TPE1", [metadata.artist]) + .setFrame("APIC", { + type: 3, + data: coverBuffer, + description: "", + }); + writer.addTag(); + } fileBuffer = Buffer.from(writer.arrayBuffer); } catch (error) { sendError(win, error); @@ -70,3 +72,4 @@ function handle(win) { } module.exports = handle; +module.exports.sendError = sendError; diff --git a/plugins/downloader/menu.js b/plugins/downloader/menu.js new file mode 100644 index 00000000..4eda01a5 --- /dev/null +++ b/plugins/downloader/menu.js @@ -0,0 +1,63 @@ +const { existsSync, mkdirSync } = require("fs"); +const { join } = require("path"); +const { URL } = require("url"); + +const { ipcMain } = require("electron"); +const is = require("electron-is"); +const ytpl = require("ytpl"); + +const { sendError } = require("./back"); +const { defaultMenuDownloadLabel, getFolder } = require("./utils"); + +let downloadLabel = defaultMenuDownloadLabel; + +module.exports = (win, options, refreshMenu) => [ + { + label: downloadLabel, + click: async () => { + const currentURL = win.webContents.getURL(); + const playlistID = new URL(currentURL).searchParams.get("list"); + if (!playlistID) { + sendError(win, new Error("No playlist ID found")); + return; + } + + const playlist = await ytpl(playlistID); + const playlistTitle = playlist.title; + + const folder = getFolder(options.downloadFolder); + const playlistFolder = join(folder, playlistTitle); + if (existsSync(playlistFolder)) { + sendError( + win, + new Error(`The folder ${playlistFolder} already exists`) + ); + return; + } + mkdirSync(playlistFolder, { recursive: true }); + + ipcMain.on("downloader-feedback", (_, feedback) => { + downloadLabel = feedback; + refreshMenu(); + }); + + downloadLabel = `Downloading "${playlistTitle}"`; + refreshMenu(); + + if (is.dev()) { + console.log( + `Downloading playlist "${playlistTitle}" (${playlist.items.length} songs)` + ); + } + + playlist.items.slice(0, options.playlistMaxItems).forEach((song) => { + win.webContents.send( + "downloader-download-playlist", + song, + playlistTitle, + options + ); + }); + }, + }, +]; diff --git a/plugins/downloader/utils.js b/plugins/downloader/utils.js new file mode 100644 index 00000000..3e0727cb --- /dev/null +++ b/plugins/downloader/utils.js @@ -0,0 +1,4 @@ +const downloadsFolder = require("downloads-folder"); + +module.exports.getFolder = (customFolder) => customFolder || downloadsFolder(); +module.exports.defaultMenuDownloadLabel = "Download playlist"; diff --git a/plugins/downloader/youtube-dl.js b/plugins/downloader/youtube-dl.js index 3541391f..65907e2b 100644 --- a/plugins/downloader/youtube-dl.js +++ b/plugins/downloader/youtube-dl.js @@ -1,7 +1,8 @@ const { randomBytes } = require("crypto"); +const { writeFileSync } = require("fs"); const { join } = require("path"); -const downloadsFolder = require("downloads-folder"); +const Mutex = require("async-mutex").Mutex; const { ipcRenderer } = require("electron"); const is = require("electron-is"); const filenamify = require("filenamify"); @@ -12,8 +13,9 @@ const filenamify = require("filenamify"); const FFmpeg = require("@ffmpeg/ffmpeg/dist/ffmpeg.min"); const ytdl = require("ytdl-core"); -const { triggerActionSync } = require("../utils"); +const { triggerAction, triggerActionSync } = require("../utils"); const { ACTIONS, CHANNEL } = require("./actions.js"); +const { defaultMenuDownloadLabel, getFolder } = require("./utils"); const { createFFmpeg } = FFmpeg; const ffmpeg = createFFmpeg({ @@ -21,13 +23,16 @@ const ffmpeg = createFFmpeg({ logger: () => {}, // console.log, progress: () => {}, // console.log, }); +const ffmpegMutex = new Mutex(); const downloadVideoToMP3 = ( videoUrl, sendFeedback, sendError, reinit, - options + options, + metadata = undefined, + subfolder = "" ) => { sendFeedback("Downloading…"); @@ -66,9 +71,18 @@ const downloadVideoToMP3 = ( } }) .on("error", sendError) - .on("end", () => { + .on("end", async () => { const buffer = Buffer.concat(chunks); - toMP3(videoName, buffer, sendFeedback, sendError, reinit, options); + await toMP3( + videoName, + buffer, + sendFeedback, + sendError, + reinit, + options, + metadata, + subfolder + ); }); }; @@ -78,10 +92,13 @@ const toMP3 = async ( sendFeedback, sendError, reinit, - options + options, + existingMetadata = undefined, + subfolder = "" ) => { const safeVideoName = randomBytes(32).toString("hex"); const extension = options.extension || "mp3"; + const releaseFFmpegMutex = await ffmpegMutex.acquire(); try { if (!ffmpeg.isLoaded()) { @@ -93,7 +110,7 @@ const toMP3 = async ( ffmpeg.FS("writeFile", safeVideoName, buffer); sendFeedback("Converting…"); - const metadata = getMetadata(); + const metadata = existingMetadata || getMetadata(); await ffmpeg.run( "-i", safeVideoName, @@ -102,24 +119,31 @@ const toMP3 = async ( safeVideoName + "." + extension ); - const folder = options.downloadFolder || downloadsFolder(); + const folder = getFolder(options.downloadFolder); const name = metadata - ? `${metadata.artist} - ${metadata.title}` + ? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}` : videoName; const filename = filenamify(name + "." + extension, { replacement: "_", }); - // Add the metadata - sendFeedback("Adding metadata…"); - ipcRenderer.send( - "add-metadata", - join(folder, filename), - ffmpeg.FS("readFile", safeVideoName + "." + extension) - ); - ipcRenderer.once("add-metadata-done", reinit); + const filePath = join(folder, subfolder, filename); + const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension); + + if (existingMetadata) { + writeFileSync(filePath, fileBuffer); + reinit(); + } else { + // Add the metadata + sendFeedback("Adding metadata…"); + ipcRenderer.send("add-metadata", filePath, fileBuffer); + ipcRenderer.once("add-metadata-done", reinit); + sendFeedback("Finished converting", metadata); + } } catch (e) { sendError(e); + } finally { + releaseFFmpegMutex(); } }; @@ -133,13 +157,34 @@ const getFFmpegMetadataArgs = (metadata) => { } return [ - "-metadata", - `title=${metadata.title}`, - "-metadata", - `artist=${metadata.artist}`, + ...(metadata.title ? ["-metadata", `title=${metadata.title}`] : []), + ...(metadata.artist ? ["-metadata", `artist=${metadata.artist}`] : []), ]; }; module.exports = { downloadVideoToMP3, }; + +ipcRenderer.on( + "downloader-download-playlist", + (_, songMetadata, playlistFolder, options) => { + const reinit = () => + ipcRenderer.send("downloader-feedback", defaultMenuDownloadLabel); + + downloadVideoToMP3( + songMetadata.url, + (feedback) => { + ipcRenderer.send("downloader-feedback", feedback); + }, + (error) => { + triggerAction(CHANNEL, ACTIONS.ERROR, error); + reinit(); + }, + reinit, + options, + songMetadata, + playlistFolder + ); + } +); diff --git a/plugins/navigation/back.js b/plugins/navigation/back.js index 6d0d0a2a..4c3e00dc 100644 --- a/plugins/navigation/back.js +++ b/plugins/navigation/back.js @@ -4,7 +4,10 @@ const { injectCSS, listenAction } = require("../utils"); const { ACTIONS, CHANNEL } = require("./actions.js"); function handle(win) { - injectCSS(win.webContents, path.join(__dirname, "style.css")); + injectCSS(win.webContents, path.join(__dirname, "style.css"), () => { + win.webContents.send("navigation-css-ready"); + }); + listenAction(CHANNEL, (event, action) => { switch (action) { case ACTIONS.NEXT: diff --git a/plugins/navigation/front.js b/plugins/navigation/front.js index 92bc1325..d89c9891 100644 --- a/plugins/navigation/front.js +++ b/plugins/navigation/front.js @@ -1,15 +1,19 @@ +const { ipcRenderer } = require("electron"); + const { ElementFromFile, templatePath } = require("../utils"); function run() { - const forwardButton = ElementFromFile( - templatePath(__dirname, "forward.html") - ); - const backButton = ElementFromFile(templatePath(__dirname, "back.html")); - const menu = document.querySelector("ytmusic-pivot-bar-renderer"); + ipcRenderer.on("navigation-css-ready", () => { + const forwardButton = ElementFromFile( + templatePath(__dirname, "forward.html") + ); + const backButton = ElementFromFile(templatePath(__dirname, "back.html")); + const menu = document.querySelector("ytmusic-pivot-bar-renderer"); - if (menu) { - menu.prepend(backButton, forwardButton); - } + if (menu) { + menu.prepend(backButton, forwardButton); + } + }); } module.exports = run; diff --git a/plugins/navigation/templates/back.html b/plugins/navigation/templates/back.html index 2a675163..b872ada5 100644 --- a/plugins/navigation/templates/back.html +++ b/plugins/navigation/templates/back.html @@ -20,7 +20,7 @@ preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope iron-icon" - style="pointer-events: none; display: block; width: 100%; height: 100%;" + style="pointer-events: none; display: block; width: 100%; height: 100%" > { }); }; -module.exports.injectCSS = (webContents, filepath) => { - webContents.on("did-finish-load", () => { - webContents.insertCSS(fs.readFileSync(filepath, "utf8")); +module.exports.injectCSS = (webContents, filepath, cb = undefined) => { + webContents.on("did-finish-load", async () => { + await webContents.insertCSS(fs.readFileSync(filepath, "utf8")); + if (cb) { + cb(); + } }); }; diff --git a/yarn.lock b/yarn.lock index e37cca43..76f32f7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1661,6 +1661,13 @@ async-exit-hook@^2.0.1: resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw== +async-mutex@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.3.1.tgz#7033af665f1c7cebed8b878267a43ba9e77c5f67" + integrity sha512-vRfQwcqBnJTLzVQo72Sf7KIUbcSUP5hNchx6udI1U6LuPQpfePgdjJzlCe76yFZ8pxlLjn9lwcl/Ya0TSOv0Tw== + dependencies: + tslib "^2.1.0" + async@0.9.x: version "0.9.2" resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" @@ -6152,7 +6159,7 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== -miniget@^4.0.0: +miniget@^4.0.0, miniget@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/miniget/-/miniget-4.2.0.tgz#0004e95536b192d95a7d09f4435d67b9285481d0" integrity sha512-IzTOaNgBw/qEpzkPTE7X2cUVXQfSKbG8w52Emi93zb+Zya2ZFrbmavpixzebuDJD9Ku4ecbaFlC7Y1cEESzQtQ== @@ -8367,6 +8374,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + tsutils@^3.17.1: version "3.20.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.20.0.tgz#ea03ea45462e146b53d70ce0893de453ff24f698" @@ -9070,6 +9082,13 @@ ytdl-core@^4.4.5: miniget "^4.0.0" sax "^1.1.3" +ytpl@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/ytpl/-/ytpl-2.0.5.tgz#c56900bccaf96e289304de647bc861121f61223e" + integrity sha512-8hc+f3pijaogj1yoZTCGImMDS4x0ogFPDsx1PefNQ+2EAhJMm1K4brcYT9zpJhPi9SXh+O103pEIHDw3+dAhxA== + dependencies: + miniget "^4.1.0" + zip-stream@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.0.4.tgz#3a8f100b73afaa7d1ae9338d910b321dec77ff3a" diff --git a/youtube-music.css b/youtube-music.css index d9c7b3ec..fe5eb326 100644 --- a/youtube-music.css +++ b/youtube-music.css @@ -5,7 +5,7 @@ /* Allow window dragging */ ytmusic-nav-bar { -webkit-user-select: none; - -webkit-app-region : drag; + -webkit-app-region: drag; } iron-icon,