diff --git a/package.json b/package.json index 6fa8f076..f8f40050 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,6 @@ "browser-id3-writer": "^4.4.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", - "chokidar": "^3.5.3", "custom-electron-prompt": "^1.5.1", "custom-electron-titlebar": "^4.1.5", "electron-better-web-request": "^1.0.1", diff --git a/plugins/downloader/back-downloader.js b/plugins/downloader/back-downloader.js index 63923301..5e64ffb3 100644 --- a/plugins/downloader/back-downloader.js +++ b/plugins/downloader/back-downloader.js @@ -1,38 +1,67 @@ const { existsSync, mkdirSync, createWriteStream, writeFileSync } = require('fs'); -const { ipcMain, app } = require("electron"); const { join } = require("path"); -const { Innertube, UniversalCache, Utils } = require('youtubei.js'); -const filenamify = require("filenamify"); -const ID3Writer = require("browser-id3-writer"); - const { fetchFromGenius } = require("../lyrics-genius/back"); const { isEnabled } = require("../../config/plugins"); const { getImage } = require("../../providers/song-info"); -const { cropMaxWidth } = require("./utils"); - const { sendError } = require("./back"); -const { presets } = require('./utils'); +const { presets, cropMaxWidth, getFolder, setBadge, sendFeedback: sendFeedback_ } = require('./utils'); + +const { ipcMain, app, dialog } = require("electron"); +const is = require("electron-is"); +const { Innertube, UniversalCache, Utils } = require('youtubei.js'); +const ytpl = require("ytpl"); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid + +const filenamify = require("filenamify"); +const ID3Writer = require("browser-id3-writer"); +const { randomBytes } = require("crypto"); +const Mutex = require("async-mutex").Mutex; +const ffmpeg = require("@ffmpeg/ffmpeg").createFFmpeg({ + log: false, + logger: () => { }, // console.log, + progress: () => { }, // console.log, +}); +const ffmpegMutex = new Mutex(); /** @type {Innertube} */ let yt; let options; +let win; +let playingUrl = undefined; -module.exports = async (options_) => { +module.exports = async (win_, options_) => { options = options_; + win = win_; yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true }); ipcMain.handle("download-song", (_, url) => downloadSong(url)); + ipcMain.on("video-src-changed", async (_, data) => { + playingUrl = JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical; + }); + ipcMain.on("download-playlist-request", async (_event, url) => downloadPlaylist(url, win, options)); }; -async function downloadSong(url, playlistFolder = undefined, trackId = undefined) { +async function downloadSong(url, playlistFolder = undefined, trackId = undefined, increasePlaylistProgress = ()=>{}) { + const sendFeedback = (message, progress) => { + if (!playlistFolder) { + sendFeedback_(win, message); + if (!isNaN(progress)) { + win.setProgressBar(progress); + } + } + }; + + sendFeedback(`Downloading...`, 2); const metadata = await getMetadata(url); metadata.trackId = trackId; - const stream = await yt.download(metadata.id, { + const download_options = { type: 'audio', // audio, video or video+audio quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on. format: 'any' // media container format - }); + }; + + const format = metadata.info.chooseFormat(download_options); + const stream = await metadata.info.download(download_options); console.info(`Downloading ${metadata.artist} - ${metadata.title} {${metadata.id}}...`); @@ -54,30 +83,32 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined } if (!presets[options.preset]) { - const fileBuffer = await toMP3(iterableStream, metadata); - console.info('writing id3 tags...'); // DELETE - writeFileSync(filePath, await writeID3(fileBuffer, metadata)); - console.info('done writing id3 tags!'); // DELETE + const fileBuffer = await toMP3(iterableStream, metadata, format.content_length, sendFeedback, increasePlaylistProgress); + writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback)); } else { const file = createWriteStream(filePath); - //stream.pipeTo(file); + let downloaded = 0; + let total = format.content_length; + for await (const chunk of iterableStream) { + downloaded += chunk.length; + const ratio = downloaded / total; + const progress = Math.floor(ratio * 100); + sendFeedback("Download: " + progress + "%", ratio); + increasePlaylistProgress(ratio); file.write(chunk); } await ffmpegWriteTags(filePath, metadata, presets[options.preset]?.ffmpegArgs); + sendFeedback(null, -1); } + sendFeedback(null, -1); console.info(`${filePath} - Done!`, '\n'); } module.exports.downloadSong = downloadSong; -function getIdFromUrl(url) { - const match = url.match(/v=([^&]+)/); - return match ? match[1] : null; -} - async function getMetadata(url) { - const id = getIdFromUrl(url); + const id = url.match(/v=([^&]+)/)?.[1]; const info = await yt.music.getInfo(id); return { @@ -86,11 +117,14 @@ async function getMetadata(url) { artist: info.basic_info.author, album: info.player_overlays?.browser_media_session?.album?.text, image: info.basic_info.thumbnail[0].url, + info }; } -async function writeID3(buffer, metadata) { +async function writeID3(buffer, metadata, sendFeedback) { try { + sendFeedback("Writing ID3 tags..."); + const nativeImage = cropMaxWidth(await getImage(metadata.image)); const coverBuffer = nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null; @@ -130,36 +164,33 @@ async function writeID3(buffer, metadata) { } } - -const { randomBytes } = require("crypto"); -const Mutex = require("async-mutex").Mutex; -const ffmpeg = require("@ffmpeg/ffmpeg").createFFmpeg({ - log: false, - logger: () => { }, // console.log, - progress: () => { }, // console.log, -}); - -const ffmpegMutex = new Mutex(); - -async function toMP3(stream, metadata, extension = "mp3") { +async function toMP3(stream, metadata, content_length, sendFeedback, increasePlaylistProgress = () => { }, extension = "mp3") { const chunks = []; + let downloaded = 0; + let total = content_length; for await (const chunk of stream) { + downloaded += chunk.length; chunks.push(chunk); + const ratio = downloaded / total; + const progress = Math.floor(ratio * 100); + sendFeedback("Download: " + progress + "%", ratio); + increasePlaylistProgress(ratio); } + sendFeedback("Loading…", 2); // indefinite progress bar after download + const buffer = Buffer.concat(chunks); const safeVideoName = randomBytes(32).toString("hex"); const releaseFFmpegMutex = await ffmpegMutex.acquire(); try { if (!ffmpeg.isLoaded()) { - // sendFeedback("Loading…", 2); // indefinite progress bar after download await ffmpeg.load(); } - // sendFeedback("Preparing file…"); + sendFeedback("Preparing file…"); ffmpeg.FS("writeFile", safeVideoName, buffer); - // sendFeedback("Converting…"); + sendFeedback("Converting…"); await ffmpeg.run( "-i", @@ -168,9 +199,9 @@ async function toMP3(stream, metadata, extension = "mp3") { safeVideoName + "." + extension ); - // sendFeedback("Saving…"); + sendFeedback("Saving…"); - return ffmpeg.FS("readFile", safeVideoName + "." + extension); + return ffmpeg.FS("readFile", safeVideoName + "." + extension); } catch (e) { sendError(e); } finally { @@ -212,3 +243,104 @@ function getFFmpegMetadataArgs(metadata) { ...(metadata.trackId ? ["-metadata", `track=${metadata.trackId}`] : []), ]; }; + +// Playlist radio modifier needs to be cut from playlist ID +const INVALID_PLAYLIST_MODIFIER = 'RDAMPL'; + +const getPlaylistID = aURL => { + const result = aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist"); + if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) { + return result.slice(6) + } + return result; +}; + +async function downloadPlaylist(givenUrl) { + if (givenUrl) { + try { + givenUrl = new URL(givenUrl); + } catch { + givenUrl = undefined; + }; + } + const playlistId = getPlaylistID(givenUrl) + || getPlaylistID(new URL(win.webContents.getURL())) + || getPlaylistID(new URL(playingUrl)); + + if (!playlistId) { + sendError(new Error("No playlist ID found")); + return; + } + + const sendFeedback = message => sendFeedback_(win, message); + + console.log(`trying to get playlist ID: '${playlistId}'`); + sendFeedback("Getting playlist info…"); + let playlist; + try { + playlist = await ytpl(playlistId, { + limit: options.playlistMaxItems || Infinity, + }); + } catch (e) { + sendError(e); + return; + } + let isAlbum = playlist.title.startsWith('Album - '); + if (isAlbum) { + playlist.title = playlist.title.slice(8); + } + const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' }); + + const folder = getFolder(options.downloadFolder); + const playlistFolder = join(folder, safePlaylistTitle); + if (existsSync(playlistFolder)) { + sendError(new Error(`The folder ${playlistFolder} already exists`)); + return; + } + mkdirSync(playlistFolder, { recursive: true }); + + dialog.showMessageBox({ + type: "info", + buttons: ["OK"], + title: "Started Download", + message: `Downloading Playlist "${playlist.title}"`, + detail: `(${playlist.items.length} songs)`, + }); + + if (is.dev()) { + console.log( + `Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})` + ); + } + + win.setProgressBar(2); // starts with indefinite bar + + setBadge(playlist.items.length); + + let counter = 1; + + const progressStep = 1 / playlist.items.length; + + const increaseProgress = (itemPercentage) => { + const currentProgress = (counter - 1) / playlist.items.length; + const newProgress = currentProgress + (progressStep * itemPercentage); + win.setProgressBar(newProgress); + }; + + try { + for (const song of playlist.items) { + sendFeedback(`Downloading ${counter}/${playlist.items.length}...`); + const trackId = isAlbum ? counter : undefined; + await downloadSong(song.url, playlistFolder, trackId, increaseProgress).catch((e) => sendError(e)); + win.setProgressBar(counter / playlist.items.length); + setBadge(playlist.items.length - counter); + counter++; + } + } catch (e) { + sendError(e); + } finally { + win.setProgressBar(-1); // close progress bar + setBadge(0); // close badge counter + sendFeedback(); // clear feedback + } +} diff --git a/plugins/downloader/back.js b/plugins/downloader/back.js index 379d6271..bb980675 100644 --- a/plugins/downloader/back.js +++ b/plugins/downloader/back.js @@ -4,12 +4,16 @@ const { dialog } = require("electron"); const registerCallback = require("../../providers/song-info"); const { injectCSS, listenAction } = require("../utils"); +const { setBadge, sendFeedback } = require("./utils"); const { ACTIONS, CHANNEL } = require("./actions.js"); let win = {}; const sendError = (error) => { win.setProgressBar(-1); // close progress bar + setBadge(0); // close badge + sendFeedback(); // reset feedback + console.error(error); dialog.showMessageBox({ @@ -28,7 +32,7 @@ function handle(win_, options) { win = win_; injectCSS(win.webContents, join(__dirname, "style.css")); - require("./back-downloader")(options); + require("./back-downloader")(win, options); registerCallback((info) => { nowPlayingMetadata = info; diff --git a/plugins/downloader/front.js b/plugins/downloader/front.js index 97d73397..e534ed71 100644 --- a/plugins/downloader/front.js +++ b/plugins/downloader/front.js @@ -40,7 +40,7 @@ const baseUrl = defaultConfig.url; // contextBridge.exposeInMainWorld("downloader", { // download: () => { global.download = () => { - triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar + //triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar let metadata; let videoUrl = getSongMenu() // selector of first button which is always "Start Radio" @@ -60,7 +60,7 @@ global.download = () => { videoUrl = metadata.url || window.location.href; } - ipcRenderer.invoke('download-song', videoUrl).finally(() => triggerAction(CHANNEL, ACTIONS.PROGRESS, -1)); + ipcRenderer.invoke('download-song', videoUrl)//.finally(() => triggerAction(CHANNEL, ACTIONS.PROGRESS, -1)); return; }; @@ -73,6 +73,14 @@ function observeMenu(options) { subtree: true, }); }, { once: true, passive: true }) + + ipcRenderer.on('downloader-feedback', (_, feedback)=> { + if (!progress) { + console.warn("Cannot update progress"); + } else { + progress.innerHTML = feedback || "Download"; + } + }); } module.exports = observeMenu; diff --git a/plugins/downloader/menu.js b/plugins/downloader/menu.js index 81959e35..dd572693 100644 --- a/plugins/downloader/menu.js +++ b/plugins/downloader/menu.js @@ -1,41 +1,12 @@ -const { existsSync, mkdirSync } = require("fs"); -const { join } = require("path"); - -const { dialog, ipcMain } = require("electron"); -const is = require("electron-is"); -const ytpl = require("ytpl"); -const chokidar = require('chokidar'); -const filenamify = require('filenamify'); +const { dialog } = require("electron"); const { setMenuOptions } = require("../../config/plugins"); -const { sendError } = require("./back"); -const { downloadSong } = require("./back-downloader"); -const { defaultMenuDownloadLabel, getFolder, presets, setBadge } = require("./utils"); +const { downloadPlaylist } = require("./back-downloader"); +const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils"); let downloadLabel = defaultMenuDownloadLabel; -let playingUrl = undefined; -let callbackIsRegistered = false; - -// Playlist radio modifier needs to be cut from playlist ID -const INVALID_PLAYLIST_MODIFIER = 'RDAMPL'; - -const getPlaylistID = aURL => { - const result = aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist"); - if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) { - return result.slice(6) - } - return result; -}; module.exports = (win, options) => { - if (!callbackIsRegistered) { - ipcMain.on("video-src-changed", async (_, data) => { - playingUrl = JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical; - }); - ipcMain.on("download-playlist-request", async (_event, url) => downloadPlaylist(url, win, options)); - callbackIsRegistered = true; - } - return [ { label: downloadLabel, @@ -68,95 +39,3 @@ module.exports = (win, options) => { }, ]; }; - -async function downloadPlaylist(givenUrl, win, options) { - if (givenUrl) { - try { - givenUrl = new URL(givenUrl); - } catch { - givenUrl = undefined; - }; - } - const playlistId = getPlaylistID(givenUrl) - || getPlaylistID(new URL(win.webContents.getURL())) - || getPlaylistID(new URL(playingUrl)); - - if (!playlistId) { - sendError(new Error("No playlist ID found")); - return; - } - - console.log(`trying to get playlist ID: '${playlistId}'`); - let playlist; - try { - playlist = await ytpl(playlistId, { - limit: options.playlistMaxItems || Infinity, - }); - } catch (e) { - sendError(e); - return; - } - let isAlbum = playlist.title.startsWith('Album - '); - if (isAlbum) { - playlist.title = playlist.title.slice(8); - } - const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' }); - - const folder = getFolder(options.downloadFolder); - const playlistFolder = join(folder, safePlaylistTitle); - if (existsSync(playlistFolder)) { - sendError(new Error(`The folder ${playlistFolder} already exists`)); - return; - } - mkdirSync(playlistFolder, { recursive: true }); - - dialog.showMessageBox({ - type: "info", - buttons: ["OK"], - title: "Started Download", - message: `Downloading Playlist "${playlist.title}"`, - detail: `(${playlist.items.length} songs)`, - }); - - if (is.dev()) { - console.log( - `Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})` - ); - } - - win.setProgressBar(2); // starts with indefinite bar - - let downloadCount = 0; - setBadge(playlist.items.length); - - let dirWatcher = chokidar.watch(playlistFolder); - const closeDirWatcher = () => { - if (dirWatcher) { - win.setProgressBar(-1); // close progress bar - setBadge(0); // close badge counter - dirWatcher.close().then(() => (dirWatcher = null)); - } - }; - dirWatcher.on('add', () => { - downloadCount += 1; - if (downloadCount >= playlist.items.length) { - closeDirWatcher(); - } else { - win.setProgressBar(downloadCount / playlist.items.length); - setBadge(playlist.items.length - downloadCount); - } - }); - - let counter = 1; - - try { - for (const song of playlist.items) { - const trackId = isAlbum ? counter++ : undefined; - await downloadSong(song.url, playlistFolder, trackId).catch((e) => sendError(e)); - } - } catch (e) { - sendError(e); - } finally { - closeDirWatcher(); - } -} diff --git a/plugins/downloader/utils.js b/plugins/downloader/utils.js index 0569a6d1..def066cb 100644 --- a/plugins/downloader/utils.js +++ b/plugins/downloader/utils.js @@ -4,6 +4,10 @@ const is = require('electron-is'); module.exports.getFolder = customFolder => customFolder || app.getPath("downloads"); module.exports.defaultMenuDownloadLabel = "Download playlist"; +module.exports.sendFeedback = (win, message) => { + win.webContents.send("downloader-feedback", message); +}; + const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"]; module.exports.urlToJPG = (imgUrl, videoId) => { if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl; diff --git a/yarn.lock b/yarn.lock index 929d11d3..971b652a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1365,16 +1365,6 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:~3.1.2": - version: 3.1.3 - resolution: "anymatch@npm:3.1.3" - dependencies: - normalize-path: ^3.0.0 - picomatch: ^2.0.4 - checksum: 3e044fd6d1d26545f235a9fe4d7a534e2029d8e59fa7fd9f2a6eb21230f6b5380ea1eaf55136e60cbf8e613544b3b766e7a6fa2102e2a3a117505466e3025dc2 - languageName: node - linkType: hard - "app-builder-bin@npm:4.0.0": version: 4.0.0 resolution: "app-builder-bin@npm:4.0.0" @@ -1682,13 +1672,6 @@ __metadata: languageName: node linkType: hard -"binary-extensions@npm:^2.0.0": - version: 2.2.0 - resolution: "binary-extensions@npm:2.2.0" - checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8 - languageName: node - linkType: hard - "bindings@npm:^1.2.1": version: 1.5.0 resolution: "bindings@npm:1.5.0" @@ -1754,7 +1737,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.2, braces@npm:~3.0.2": +"braces@npm:^3.0.2": version: 3.0.2 resolution: "braces@npm:3.0.2" dependencies: @@ -2065,25 +2048,6 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3": - version: 3.5.3 - resolution: "chokidar@npm:3.5.3" - dependencies: - anymatch: ~3.1.2 - braces: ~3.0.2 - fsevents: ~2.3.2 - glob-parent: ~5.1.2 - is-binary-path: ~2.1.0 - is-glob: ~4.0.1 - normalize-path: ~3.0.0 - readdirp: ~3.6.0 - dependenciesMeta: - fsevents: - optional: true - checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c - languageName: node - linkType: hard - "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -4102,25 +4066,6 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:~2.3.2": - version: 2.3.2 - resolution: "fsevents@npm:2.3.2" - dependencies: - node-gyp: latest - checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f - conditions: os=darwin - languageName: node - linkType: hard - -"fsevents@patch:fsevents@~2.3.2#~builtin": - version: 2.3.2 - resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1" - dependencies: - node-gyp: latest - conditions: os=darwin - languageName: node - linkType: hard - "function-bind@npm:^1.1.1": version: 1.1.1 resolution: "function-bind@npm:1.1.1" @@ -4247,7 +4192,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": +"glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -4896,15 +4841,6 @@ __metadata: languageName: node linkType: hard -"is-binary-path@npm:~2.1.0": - version: 2.1.0 - resolution: "is-binary-path@npm:2.1.0" - dependencies: - binary-extensions: ^2.0.0 - checksum: 84192eb88cff70d320426f35ecd63c3d6d495da9d805b19bc65b518984b7c0760280e57dbf119b7e9be6b161784a5a673ab2c6abe83abb5198a432232ad5b35c - languageName: node - linkType: hard - "is-boolean-object@npm:^1.1.0": version: 1.1.2 resolution: "is-boolean-object@npm:1.1.2" @@ -5016,7 +4952,7 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: @@ -6363,13 +6299,6 @@ __metadata: languageName: node linkType: hard -"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": - version: 3.0.0 - resolution: "normalize-path@npm:3.0.0" - checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 - languageName: node - linkType: hard - "normalize-url@npm:^6.0.1": version: 6.1.0 resolution: "normalize-url@npm:6.1.0" @@ -6860,7 +6789,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf @@ -7269,15 +7198,6 @@ __metadata: languageName: node linkType: hard -"readdirp@npm:~3.6.0": - version: 3.6.0 - resolution: "readdirp@npm:3.6.0" - dependencies: - picomatch: ^2.2.1 - checksum: 1ced032e6e45670b6d7352d71d21ce7edf7b9b928494dcaba6f11fba63180d9da6cd7061ebc34175ffda6ff529f481818c962952004d273178acd70f7059b320 - languageName: node - linkType: hard - "redent@npm:^4.0.0": version: 4.0.0 resolution: "redent@npm:4.0.0" @@ -8991,7 +8911,6 @@ __metadata: browser-id3-writer: ^4.4.0 butterchurn: ^2.6.7 butterchurn-presets: ^2.4.7 - chokidar: ^3.5.3 custom-electron-prompt: ^1.5.1 custom-electron-titlebar: ^4.1.5 del-cli: ^5.0.0