diff --git a/config/defaults.js b/config/defaults.js index 2f345ab4..6aef55c9 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -41,7 +41,11 @@ const defaultConfig = { api_key: "04d76faaac8726e60988e14c105d421a", // api key registered by @semvis123 secret: "a5d2a36fdf64819290f6982481eaffa2", suffixesToRemove: [' - Topic', 'VEVO'] // removes suffixes of the artist name, for better recognition - } + }, + 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 222fac90..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" }, { @@ -165,6 +189,38 @@ const mainMenuTemplate = (win) => [ }, ], }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { type: "separator" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { role: "resetZoom" }, + ], + }, + { + label: "Navigation", + submenu: [ + { + label: "Go back", + click: () => { + if (win.webContents.canGoBack()) { + win.webContents.goBack(); + } + }, + }, + { + label: "Go forward", + click: () => { + if (win.webContents.canGoForward()) { + win.webContents.goForward(); + } + }, + }, + ], + }, ]; module.exports.mainMenuTemplate = mainMenuTemplate; diff --git a/package.json b/package.json index df4725e1..79a9cbf7 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@ffmpeg/ffmpeg": "^0.9.7", "YoutubeNonStop": "git://github.com/lawfx/YoutubeNonStop.git#v0.8.1", "axios": "^0.21.1", + "async-mutex": "^0.3.1", "browser-id3-writer": "^4.4.0", "discord-rpc": "^3.2.0", "downloads-folder": "^3.0.1", @@ -80,7 +81,8 @@ "md5": "^2.3.0", "node-fetch": "^2.6.1", "open": "^8.0.3", - "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 1d7e8e21..bf9e0e26 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 @@ -32,7 +34,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 f4ac5ed3..f1a14d40 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" @@ -6147,7 +6154,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== @@ -8362,6 +8369,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" @@ -9065,6 +9077,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 cfba9967..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, @@ -17,11 +17,11 @@ a { /* custom style for navbar */ ytmusic-app-layout { - --ytmusic-nav-bar-height: 85px; + --ytmusic-nav-bar-height: 90px; } ytmusic-search-box.ytmusic-nav-bar { - margin-top: 12px; + margin-top: 15px; } /* Blocking annoying elements */