diff --git a/index.js b/index.js index 6de4c18be0..dcc23eb794 100644 --- a/index.js +++ b/index.js @@ -34,11 +34,6 @@ autoUpdater.autoDownload = false; const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.exit(); -app.commandLine.appendSwitch( - "js-flags", - // WebAssembly flags - "--experimental-wasm-threads" -); app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); // Required for downloader app.allowRendererProcessReuse = true; // https://github.com/electron/electron/issues/18397 if (config.get("options.disableHardwareAcceleration")) { diff --git a/package.json b/package.json index 9b407d0872..1f60e8729a 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.4", "custom-electron-titlebar": "^4.1.6", "electron-better-web-request": "^1.0.1", @@ -135,8 +134,7 @@ "node-fetch": "^2.6.8", "simple-youtube-age-restriction-bypass": "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4", "vudio": "^2.1.1", - "youtubei.js": "^2.9.0", - "ytdl-core": "^4.11.1", + "youtubei.js": "^3.1.1", "ytpl": "^2.3.0" }, "devDependencies": { diff --git a/plugins/downloader/actions.js b/plugins/downloader/actions.js deleted file mode 100644 index 0d6c342648..0000000000 --- a/plugins/downloader/actions.js +++ /dev/null @@ -1,11 +0,0 @@ -const CHANNEL = "downloader"; -const ACTIONS = { - ERROR: "error", - METADATA: "metadata", - PROGRESS: "progress", -}; - -module.exports = { - CHANNEL: CHANNEL, - ACTIONS: ACTIONS, -}; diff --git a/plugins/downloader/back.js b/plugins/downloader/back.js index daea62b212..35bedfc1a5 100644 --- a/plugins/downloader/back.js +++ b/plugins/downloader/back.js @@ -1,98 +1,484 @@ -const { writeFileSync } = require("fs"); -const { join } = require("path"); - -const ID3Writer = require("browser-id3-writer"); -const { dialog, ipcMain } = require("electron"); - -const registerCallback = require("../../providers/song-info"); -const { injectCSS, listenAction } = require("../utils"); -const { cropMaxWidth } = require("./utils"); -const { ACTIONS, CHANNEL } = require("./actions.js"); -const { isEnabled } = require("../../config/plugins"); -const { getImage } = require("../../providers/song-info"); -const { fetchFromGenius } = require("../lyrics-genius/back"); - -const sendError = (win, error) => { - win.setProgressBar(-1); // close progress bar - dialog.showMessageBox({ - type: "info", - buttons: ["OK"], - title: "Error in download!", - message: "Argh! Apologies, download failed…", - detail: error.toString(), - }); +const { + existsSync, + mkdirSync, + createWriteStream, + writeFileSync, +} = require('fs'); +const { join } = require('path'); + +const { fetchFromGenius } = require('../lyrics-genius/back'); +const { isEnabled } = require('../../config/plugins'); +const { getImage } = require('../../providers/song-info'); +const { injectCSS } = 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(); + +const cache = { + getCoverBuffer: { + buffer: null, + url: null, + }, +}; + +const config = require('./config'); + +/** @type {Innertube} */ +let yt; +let win; +let playingUrl = undefined; + +const sendError = (error) => { + win.setProgressBar(-1); // close progress bar + setBadge(0); // close badge + sendFeedback_(win); // reset feedback + + console.error(error); + dialog.showMessageBox({ + type: 'info', + buttons: ['OK'], + title: 'Error in download!', + message: 'Argh! Apologies, download failed…', + detail: `${error.toString()} ${ + error.cause ? `\n\n${error.cause.toString()}` : '' + }`, + }); }; -let nowPlayingMetadata = {}; - -function handle(win) { - injectCSS(win.webContents, join(__dirname, "style.css")); - registerCallback((info) => { - nowPlayingMetadata = info; - }); - - listenAction(CHANNEL, (event, action, arg) => { - switch (action) { - case ACTIONS.ERROR: // arg = error - sendError(win, arg); - break; - case ACTIONS.METADATA: - event.returnValue = JSON.stringify(nowPlayingMetadata); - break; - case ACTIONS.PROGRESS: // arg = progress - win.setProgressBar(arg); - break; - default: - console.log("Unknown action: " + action); - } - }); - - ipcMain.on("add-metadata", async (event, filePath, songBuffer, currentMetadata) => { - let fileBuffer = songBuffer; - const songMetadata = currentMetadata.imageSrcYTPL ? // This means metadata come from ytpl.getInfo(); - { - ...currentMetadata, - image: cropMaxWidth(await getImage(currentMetadata.imageSrcYTPL)) - } : - { ...nowPlayingMetadata, ...currentMetadata }; - - try { - const coverBuffer = songMetadata.image && !songMetadata.image.isEmpty() ? - songMetadata.image.toPNG() : null; - - const writer = new ID3Writer(songBuffer); - - // Create the metadata tags - writer - .setFrame("TIT2", songMetadata.title) - .setFrame("TPE1", [songMetadata.artist]); - if (coverBuffer) { - writer.setFrame("APIC", { - type: 3, - data: coverBuffer, - description: "" - }); - } - if (isEnabled("lyrics-genius")) { - const lyrics = await fetchFromGenius(songMetadata); - if (lyrics) { - writer.setFrame("USLT", { - description: lyrics, - lyrics: lyrics, - }); - } - } - writer.addTag(); - fileBuffer = Buffer.from(writer.arrayBuffer); - } catch (error) { - sendError(win, error); - } - - writeFileSync(filePath, fileBuffer); - // Notify the youtube-dl file - event.reply("add-metadata-done"); - }); +module.exports = async (win_) => { + win = win_; + injectCSS(win.webContents, join(__dirname, 'style.css')); + + yt = await Innertube.create({ + cache: new UniversalCache(false), + generate_session_locally: true, + }); + ipcMain.on('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), + ); +}; + +module.exports.downloadSong = downloadSong; +module.exports.downloadPlaylist = downloadPlaylist; + +async function downloadSong( + url, + playlistFolder = undefined, + trackId = undefined, + increasePlaylistProgress = () => {}, +) { + try { + await downloadSongUnsafe( + url, + playlistFolder, + trackId, + increasePlaylistProgress, + ); + } catch (error) { + sendError(error); + } +} + +async function downloadSongUnsafe( + 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 id = getVideoId(url); + const info = await yt.music.getInfo(id); + + const metadata = getMetadata(info); + if (metadata.album === 'N/A') metadata.album = ''; + metadata.trackId = trackId; + + const dir = + playlistFolder || config.get('downloadFolder') || app.getPath('downloads'); + const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${ + metadata.title + }`; + + const extension = presets[config.get('preset')]?.extension || 'mp3'; + + const filename = filenamify(`${name}.${extension}`, { + replacement: '_', + maxLength: 255, + }); + const filePath = join(dir, filename); + + if (config.get('skipExisting') && existsSync(filePath)) { + sendFeedback(null, -1); + return; + } + + 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 = info.chooseFormat(download_options); + const stream = await info.download(download_options); + + console.info( + `Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`, + ); + + const iterableStream = Utils.streamToIterable(stream); + + if (!existsSync(dir)) { + mkdirSync(dir); + } + + if (!presets[config.get('preset')]) { + const fileBuffer = await iterableStreamToMP3( + iterableStream, + metadata, + format.content_length, + sendFeedback, + increasePlaylistProgress, + ); + writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback)); + } else { + const file = createWriteStream(filePath); + let downloaded = 0; + const 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[config.get('preset')]?.ffmpegArgs, + ); + sendFeedback(null, -1); + } + + sendFeedback(null, -1); + console.info(`Done: "${filePath}"`); +} + +async function iterableStreamToMP3( + stream, + metadata, + content_length, + sendFeedback, + increasePlaylistProgress = () => {}, +) { + const chunks = []; + let downloaded = 0; + const 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); + // 15% for download, 85% for conversion + // This is a very rough estimate, trying to make the progress bar look nice + increasePlaylistProgress(ratio * 0.15); + } + 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()) { + await ffmpeg.load(); + } + + sendFeedback('Preparing file…'); + ffmpeg.FS('writeFile', safeVideoName, buffer); + + sendFeedback('Converting…'); + + ffmpeg.setProgress(({ ratio }) => { + sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio); + increasePlaylistProgress(0.15 + ratio * 0.85); + }); + + await ffmpeg.run( + '-i', + safeVideoName, + ...getFFmpegMetadataArgs(metadata), + `${safeVideoName}.mp3`, + ); + + sendFeedback('Saving…'); + + return ffmpeg.FS('readFile', `${safeVideoName}.mp3`); + } catch (e) { + sendError(e); + } finally { + releaseFFmpegMutex(); + } +} + +async function getCoverBuffer(url) { + const store = cache.getCoverBuffer; + if (store.url === url) { + return store.buffer; + } + store.url = url; + + const nativeImage = cropMaxWidth(await getImage(url)); + store.buffer = + nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null; + + return store.buffer; +} + +async function writeID3(buffer, metadata, sendFeedback) { + try { + sendFeedback('Writing ID3 tags...'); + + const coverBuffer = await getCoverBuffer(metadata.image); + + const writer = new ID3Writer(buffer); + + // Create the metadata tags + writer.setFrame('TIT2', metadata.title).setFrame('TPE1', [metadata.artist]); + if (metadata.album) { + writer.setFrame('TALB', metadata.album); + } + if (coverBuffer) { + writer.setFrame('APIC', { + type: 3, + data: coverBuffer, + description: '', + }); + } + if (isEnabled('lyrics-genius')) { + const lyrics = await fetchFromGenius(metadata); + if (lyrics) { + writer.setFrame('USLT', { + description: '', + lyrics: lyrics, + }); + } + } + if (metadata.trackId) { + writer.setFrame('TRCK', metadata.trackId); + } + writer.addTag(); + return Buffer.from(writer.arrayBuffer); + } catch (e) { + sendError(e); + } +} + +async function downloadPlaylist(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: config.get('playlistMaxItems') || Infinity, + }); + } catch (e) { + sendError( + `Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${e}`, + ); + return; + } + if (playlist.items.length === 0) sendError(new Error('Playlist is empty')); + if (playlist.items.length === 1) { + sendFeedback('Playlist has only one item, downloading it directly'); + await downloadSong(playlist.items[0].url); + return; + } + const isAlbum = playlist.title.startsWith('Album - '); + if (isAlbum) { + playlist.title = playlist.title.slice(8); + } + const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' }); + + const folder = getFolder(config.get('downloadFolder')); + const playlistFolder = join(folder, safePlaylistTitle); + if (existsSync(playlistFolder)) { + if (!config.get('skipExisting')) { + sendError(new Error(`The folder ${playlistFolder} already exists`)); + return; + } + } else { + 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( + `Error downloading "${song.author.name} - ${song.title}":\n ${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 + } } -module.exports = handle; -module.exports.sendError = sendError; +async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) { + const releaseFFmpegMutex = await ffmpegMutex.acquire(); + + try { + if (!ffmpeg.isLoaded()) { + await ffmpeg.load(); + } + + await ffmpeg.run( + '-i', + filePath, + ...getFFmpegMetadataArgs(metadata), + ...ffmpegArgs, + filePath, + ); + } catch (e) { + sendError(e); + } finally { + releaseFFmpegMutex(); + } +} + +function getFFmpegMetadataArgs(metadata) { + if (!metadata) { + return; + } + + return [ + ...(metadata.title ? ['-metadata', `title=${metadata.title}`] : []), + ...(metadata.artist ? ['-metadata', `artist=${metadata.artist}`] : []), + ...(metadata.album ? ['-metadata', `album=${metadata.album}`] : []), + ...(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(INVALID_PLAYLIST_MODIFIER.length); + } + return result; +}; + +const getVideoId = (url) => { + if (typeof url === 'string') { + url = new URL(url); + } + return url.searchParams.get('v'); +}; + +const getMetadata = (info) => ({ + id: info.basic_info.id, + title: info.basic_info.title, + artist: info.basic_info.author, + album: info.player_overlays?.browser_media_session?.album?.text, + image: info.basic_info.thumbnail[0].url, +}); diff --git a/plugins/downloader/config.js b/plugins/downloader/config.js new file mode 100644 index 0000000000..12c0384edc --- /dev/null +++ b/plugins/downloader/config.js @@ -0,0 +1,3 @@ +const { PluginConfig } = require('../../config/dynamic'); +const config = new PluginConfig('downloader'); +module.exports = { ...config }; diff --git a/plugins/downloader/front.js b/plugins/downloader/front.js index 095d4968f9..e0ab119fac 100644 --- a/plugins/downloader/front.js +++ b/plugins/downloader/front.js @@ -2,97 +2,68 @@ const { ipcRenderer } = require("electron"); const { defaultConfig } = require("../../config"); const { getSongMenu } = require("../../providers/dom-elements"); -const { ElementFromFile, templatePath, triggerAction } = require("../utils"); -const { ACTIONS, CHANNEL } = require("./actions.js"); -const { downloadVideoToMP3 } = require("./youtube-dl"); +const { ElementFromFile, templatePath } = require("../utils"); let menu = null; let progress = null; const downloadButton = ElementFromFile( templatePath(__dirname, "download.html") ); -let pluginOptions = {}; -const observer = new MutationObserver(() => { +let doneFirstLoad = false; + +const menuObserver = new MutationObserver(() => { if (!menu) { menu = getSongMenu(); if (!menu) return; } if (menu.contains(downloadButton)) return; const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href; - if (menuUrl && !menuUrl.includes('watch?')) return; + if (!menuUrl?.includes('watch?') && doneFirstLoad) return; menu.prepend(downloadButton); progress = document.querySelector("#ytmcustom-download"); -}); - -const reinit = () => { - triggerAction(CHANNEL, ACTIONS.PROGRESS, -1); // closes progress bar - if (!progress) { - console.warn("Cannot update progress"); - } else { - progress.innerHTML = "Download"; - } -}; -const baseUrl = defaultConfig.url; + if (doneFirstLoad) return; + setTimeout(() => doneFirstLoad ||= true, 500); +}); // TODO: re-enable once contextIsolation is set to true // contextBridge.exposeInMainWorld("downloader", { // download: () => { global.download = () => { - triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar - let metadata; let videoUrl = getSongMenu() // selector of first button which is always "Start Radio" ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint') ?.getAttribute("href"); if (videoUrl) { if (videoUrl.startsWith('watch?')) { - videoUrl = baseUrl + "/" + videoUrl; + videoUrl = defaultConfig.url + "/" + videoUrl; } if (videoUrl.includes('?playlist=')) { ipcRenderer.send('download-playlist-request', videoUrl); return; } - metadata = null; } else { - metadata = global.songInfo; - videoUrl = metadata.url || window.location.href; + videoUrl = global.songInfo.url || window.location.href; } - downloadVideoToMP3( - videoUrl, - (feedback, ratio = undefined) => { - if (!progress) { - console.warn("Cannot update progress"); - } else { - progress.innerHTML = feedback; - } - if (ratio) { - triggerAction(CHANNEL, ACTIONS.PROGRESS, ratio); - } - }, - (error) => { - triggerAction(CHANNEL, ACTIONS.ERROR, error); - reinit(); - }, - reinit, - pluginOptions, - metadata - ); + ipcRenderer.send('download-song', videoUrl); }; -// }); - -function observeMenu(options) { - pluginOptions = { ...pluginOptions, ...options }; +module.exports = () => { document.addEventListener('apiLoaded', () => { - observer.observe(document.querySelector('ytmusic-popup-container'), { + menuObserver.observe(document.querySelector('ytmusic-popup-container'), { childList: true, subtree: true, }); }, { once: true, passive: true }) -} -module.exports = observeMenu; + ipcRenderer.on('downloader-feedback', (_, feedback) => { + if (!progress) { + console.warn("Cannot update progress"); + } else { + progress.innerHTML = feedback || "Download"; + } + }); +}; diff --git a/plugins/downloader/menu.js b/plugins/downloader/menu.js index 622370a2c7..6d58bbf2cf 100644 --- a/plugins/downloader/menu.js +++ b/plugins/downloader/menu.js @@ -1,55 +1,24 @@ -const { existsSync, mkdirSync } = require("fs"); -const { join } = require("path"); +const { dialog } = require("electron"); -const { dialog, ipcMain } = require("electron"); -const is = require("electron-is"); -const ytpl = require("ytpl"); -const chokidar = require('chokidar'); -const filenamify = require('filenamify'); - -const { setMenuOptions } = require("../../config/plugins"); -const { sendError } = require("./back"); -const { defaultMenuDownloadLabel, getFolder, presets, setBadge } = 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; - } +const { downloadPlaylist } = require("./back"); +const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils"); +const config = require("./config"); +module.exports = () => { return [ { - label: downloadLabel, - click: () => downloadPlaylist(undefined, win, options), + label: defaultMenuDownloadLabel, + click: () => downloadPlaylist(), }, { label: "Choose download folder", click: () => { - let result = dialog.showOpenDialogSync({ + const result = dialog.showOpenDialogSync({ properties: ["openDirectory", "createDirectory"], - defaultPath: getFolder(options.downloadFolder), + defaultPath: getFolder(config.get("downloadFolder")), }); if (result) { - options.downloadFolder = result[0]; - setMenuOptions("downloader", options); + config.set("downloadFolder", result[0]); } // else = user pressed cancel }, }, @@ -58,94 +27,19 @@ module.exports = (win, options) => { submenu: Object.keys(presets).map((preset) => ({ label: preset, type: "radio", + checked: config.get("preset") === preset, click: () => { - options.preset = preset; - setMenuOptions("downloader", options); + config.set("preset", preset); }, - checked: options.preset === preset || presets[preset] === undefined, })), }, + { + label: "Skip existing files", + type: "checkbox", + checked: config.get("skipExisting"), + click: (item) => { + config.set("skipExisting", item.checked); + }, + }, ]; }; - -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(win, 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(win, e); - return; - } - const safePlaylistTitle = filenamify(playlist.title, {replacement: ' '}); - - const folder = getFolder(options.downloadFolder); - const playlistFolder = join(folder, safePlaylistTitle); - if (existsSync(playlistFolder)) { - sendError( - win, - 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); - dirWatcher.on('add', () => { - downloadCount += 1; - if (downloadCount >= playlist.items.length) { - win.setProgressBar(-1); // close progress bar - setBadge(0); // close badge counter - dirWatcher.close().then(() => (dirWatcher = null)); - } else { - win.setProgressBar(downloadCount / playlist.items.length); - setBadge(playlist.items.length - downloadCount); - } - }); - - playlist.items.forEach((song) => { - win.webContents.send( - "downloader-download-playlist", - song.url, - safePlaylistTitle, - options - ); - }); -} diff --git a/plugins/downloader/utils.js b/plugins/downloader/utils.js index 7016018890..6b9c449bab 100644 --- a/plugins/downloader/utils.js +++ b/plugins/downloader/utils.js @@ -1,20 +1,12 @@ -const electron = require("electron"); +const { app } = require("electron"); const is = require('electron-is'); -module.exports.getFolder = customFolder => customFolder || electron.app.getPath("downloads"); +module.exports.getFolder = customFolder => customFolder || app.getPath("downloads"); module.exports.defaultMenuDownloadLabel = "Download playlist"; -const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"]; -module.exports.urlToJPG = (imgUrl, videoId) => { - if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl; - //it will almost never get further than hqdefault - for (const quality of orderedQualityList) { - if (imgUrl.includes(quality)) { - return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`; - } - } - return `https://img.youtube.com/vi/${videoId}/default.jpg`; -} +module.exports.sendFeedback = (win, message) => { + win.webContents.send("downloader-feedback", message); +}; module.exports.cropMaxWidth = (image) => { const imageSize = image.getSize(); @@ -41,6 +33,6 @@ module.exports.presets = { module.exports.setBadge = n => { if (is.linux() || is.macOS()) { - electron.app.setBadgeCount(n); + app.setBadgeCount(n); } } diff --git a/plugins/downloader/youtube-dl.js b/plugins/downloader/youtube-dl.js deleted file mode 100644 index 8a7b0bf1a9..0000000000 --- a/plugins/downloader/youtube-dl.js +++ /dev/null @@ -1,206 +0,0 @@ -const { randomBytes } = require("crypto"); -const { join } = require("path"); - -const Mutex = require("async-mutex").Mutex; -const { ipcRenderer } = require("electron"); -const is = require("electron-is"); -const filenamify = require("filenamify"); - -// Workaround for "Automatic publicPath is not supported in this browser" -// See https://github.com/cypress-io/cypress/issues/18435#issuecomment-1048863509 -const script = document.createElement("script"); -document.body.appendChild(script); -script.src = " "; // single space and not the empty string - -// Browser version of FFmpeg (in renderer process) instead of loading @ffmpeg/ffmpeg -// because --js-flags cannot be passed in the main process when the app is packaged -// See https://github.com/electron/electron/issues/22705 -const FFmpeg = require("@ffmpeg/ffmpeg/dist/ffmpeg.min"); -const ytdl = require("ytdl-core"); - -const { triggerAction, triggerActionSync } = require("../utils"); -const { ACTIONS, CHANNEL } = require("./actions.js"); -const { presets, urlToJPG } = require("./utils"); -const { cleanupName } = require("../../providers/song-info"); - -const { createFFmpeg } = FFmpeg; -const ffmpeg = createFFmpeg({ - log: false, - logger: () => {}, // console.log, - progress: () => {}, // console.log, -}); -const ffmpegMutex = new Mutex(); - -const downloadVideoToMP3 = async ( - videoUrl, - sendFeedback, - sendError, - reinit, - options, - metadata = undefined, - subfolder = "" -) => { - sendFeedback("Downloading…"); - - if (metadata === null) { - const { videoDetails } = await ytdl.getInfo(videoUrl); - const thumbnails = videoDetails?.thumbnails; - metadata = { - artist: - videoDetails?.media?.artist || - cleanupName(videoDetails?.author?.name) || - "", - title: videoDetails?.media?.song || videoDetails?.title || "", - imageSrcYTPL: thumbnails ? - urlToJPG(thumbnails[thumbnails.length - 1].url, videoDetails?.videoId) - : "" - } - } - - let videoName = "YouTube Music - Unknown title"; - let videoReadableStream; - try { - videoReadableStream = ytdl(videoUrl, { - filter: "audioonly", - quality: "highestaudio", - highWaterMark: 32 * 1024 * 1024, // 32 MB - requestOptions: { maxRetries: 3 }, - }); - } catch (err) { - sendError(err); - return; - } - - const chunks = []; - videoReadableStream - .on("data", (chunk) => { - chunks.push(chunk); - }) - .on("progress", (_chunkLength, downloaded, total) => { - const ratio = downloaded / total; - const progress = Math.floor(ratio * 100); - sendFeedback("Download: " + progress + "%", ratio); - }) - .on("info", (info, format) => { - videoName = info.videoDetails.title.replaceAll("|", "").toString("ascii"); - if (is.dev()) { - console.log( - "Downloading video - name:", - videoName, - "- quality:", - format.audioBitrate + "kbits/s" - ); - } - }) - .on("error", sendError) - .on("end", async () => { - const buffer = Buffer.concat(chunks); - await toMP3( - videoName, - buffer, - sendFeedback, - sendError, - reinit, - options, - metadata, - subfolder - ); - }); -}; - -const toMP3 = async ( - videoName, - buffer, - sendFeedback, - sendError, - reinit, - options, - existingMetadata = undefined, - subfolder = "" -) => { - const convertOptions = { ...presets[options.preset], ...options }; - const safeVideoName = randomBytes(32).toString("hex"); - const extension = convertOptions.extension || "mp3"; - const releaseFFmpegMutex = await ffmpegMutex.acquire(); - - try { - if (!ffmpeg.isLoaded()) { - sendFeedback("Loading…", 2); // indefinite progress bar after download - await ffmpeg.load(); - } - - sendFeedback("Preparing file…"); - ffmpeg.FS("writeFile", safeVideoName, buffer); - - sendFeedback("Converting…"); - const metadata = existingMetadata || getMetadata(); - await ffmpeg.run( - "-i", - safeVideoName, - ...getFFmpegMetadataArgs(metadata), - ...(convertOptions.ffmpegArgs || []), - safeVideoName + "." + extension - ); - - const folder = options.downloadFolder || await ipcRenderer.invoke('getDownloadsFolder'); - const name = metadata.title - ? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}` - : videoName; - const filename = filenamify(name + "." + extension, { - replacement: "_", - maxLength: 255, - }); - - const filePath = join(folder, subfolder, filename); - const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension); - - // Add the metadata - sendFeedback("Adding metadata…"); - ipcRenderer.send("add-metadata", filePath, fileBuffer, { - artist: metadata.artist, - title: metadata.title, - imageSrcYTPL: metadata.imageSrcYTPL - }); - ipcRenderer.once("add-metadata-done", reinit); - } catch (e) { - sendError(e); - } finally { - releaseFFmpegMutex(); - } -}; - -const getMetadata = () => { - return JSON.parse(triggerActionSync(CHANNEL, ACTIONS.METADATA)); -}; - -const getFFmpegMetadataArgs = (metadata) => { - if (!metadata) { - return; - } - - return [ - ...(metadata.title ? ["-metadata", `title=${metadata.title}`] : []), - ...(metadata.artist ? ["-metadata", `artist=${metadata.artist}`] : []), - ]; -}; - -module.exports = { - downloadVideoToMP3, -}; - -ipcRenderer.on( - "downloader-download-playlist", - (_, url, playlistFolder, options) => { - downloadVideoToMP3( - url, - () => {}, - (error) => { - triggerAction(CHANNEL, ACTIONS.ERROR, error); - }, - () => {}, - options, - null, - playlistFolder - ); - } -); diff --git a/yarn.lock b/yarn.lock index 0328f2359a..2ce5b9df07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -734,13 +734,6 @@ __metadata: languageName: node linkType: hard -"@protobuf-ts/runtime@npm:^2.7.0": - version: 2.8.2 - resolution: "@protobuf-ts/runtime@npm:2.8.2" - checksum: ab322e832bfb347b271a8862b8ef3db27ffa380f9c49f94acb410534586a282ebd8af96d4459f959ad0fe5fbf34183f3f4fe512e50c9a4331b742a7445b16c92 - languageName: node - linkType: hard - "@remusao/guess-url-type@npm:^1.1.2": version: 1.2.1 resolution: "@remusao/guess-url-type@npm:1.2.1" @@ -1372,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" @@ -1689,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" @@ -1761,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: @@ -2072,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" @@ -4109,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=18f3a7" - 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" @@ -4254,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 +4834,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 +4945,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: @@ -5332,12 +5261,12 @@ __metadata: languageName: node linkType: hard -"jintr@npm:^0.3.1": - version: 0.3.1 - resolution: "jintr@npm:0.3.1" +"jintr@npm:^0.4.1": + version: 0.4.1 + resolution: "jintr@npm:0.4.1" dependencies: acorn: ^8.8.0 - checksum: 1fb2454904461c3bbe6b55251dce4ac352fb3b94803773e3d8925ede4a907b5d52a2f30f3f76757c770e1785f34a3665d5cffd710c3ae99837cd157762130a24 + checksum: 9dd5932be611aa926dba90e3b1bf09afbdc8864a128dbba53f5ee8461f0ac27955fca780dfd4cbb1575e6873d0d74dd346127554a4b2cae01986fe12aad3ba09 languageName: node linkType: hard @@ -5813,16 +5742,6 @@ __metadata: languageName: node linkType: hard -"m3u8stream@npm:^0.8.6": - version: 0.8.6 - resolution: "m3u8stream@npm:0.8.6" - dependencies: - miniget: ^4.2.2 - sax: ^1.2.4 - checksum: b8f61c1101dd3ad873ff2f3d0e9e6a5139ad17e20990b89ae67f2585043bc2b727151ed85f3e58aabc8a1a95af28e1ee26f69af6ac9e8841ff68129eae2f5ac5 - languageName: node - linkType: hard - "make-fetch-happen@npm:^10.0.3": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -6359,13 +6278,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" @@ -6856,7 +6768,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 @@ -7265,15 +7177,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" @@ -7553,7 +7456,7 @@ __metadata: languageName: node linkType: hard -"sax@npm:>=0.6.0, sax@npm:^1.1.3, sax@npm:^1.2.4": +"sax@npm:>=0.6.0, sax@npm:^1.2.4": version: 1.2.4 resolution: "sax@npm:1.2.4" checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe @@ -8455,12 +8358,12 @@ __metadata: languageName: node linkType: hard -"undici@npm:^5.7.0": - version: 5.18.0 - resolution: "undici@npm:5.18.0" +"undici@npm:^5.19.1": + version: 5.20.0 + resolution: "undici@npm:5.20.0" dependencies: busboy: ^1.6.0 - checksum: 74e0f357c376c745fcca612481a5742866ae36086ad387e626255f4c4a15fc5357d9d0fa4355271b6a633d50f5556c3e85720844680c44861c66e23afca7245f + checksum: 25412a785b2bd0b12f0bb0ec47ef00aa7a611ca0e570cb7af97cffe6a42e0d78e4b15190363a43771e9002defc3c6647c1b2d52201b3f64e2196819db4d150d3 languageName: node linkType: hard @@ -8980,7 +8883,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.4 custom-electron-titlebar: ^4.1.6 del-cli: ^5.0.0 @@ -9008,32 +8910,19 @@ __metadata: simple-youtube-age-restriction-bypass: "https://gitpkg.now.sh/api/pkg.tgz?url=zerodytrash/Simple-YouTube-Age-Restriction-Bypass&commit=v2.5.4" vudio: ^2.1.1 xo: ^0.53.1 - youtubei.js: ^2.9.0 - ytdl-core: ^4.11.1 + youtubei.js: ^3.1.1 ytpl: ^2.3.0 languageName: unknown linkType: soft -"youtubei.js@npm:^2.9.0": - version: 2.9.0 - resolution: "youtubei.js@npm:2.9.0" +"youtubei.js@npm:^3.1.1": + version: 3.1.1 + resolution: "youtubei.js@npm:3.1.1" dependencies: - "@protobuf-ts/runtime": ^2.7.0 - jintr: ^0.3.1 + jintr: ^0.4.1 linkedom: ^0.14.12 - undici: ^5.7.0 - checksum: 0b9d86c1ec7297ee41b9013d6cb951976d82b2775d9d9d5abf0447d7acb9f36b07ebb689710bf8ccfa256a6f56088f49b699fb1a3e5bac2b0ea7d2daa508c109 - languageName: node - linkType: hard - -"ytdl-core@npm:^4.11.1": - version: 4.11.2 - resolution: "ytdl-core@npm:4.11.2" - dependencies: - m3u8stream: ^0.8.6 - miniget: ^4.2.2 - sax: ^1.1.3 - checksum: 57df38b5b1e4955db0e0c0be8d185f34de9eaee102ad1281d69de91628230cc84e8d46d278409eafa68c4aab4085a0f9fe8de30e9ea8644e011e20cae7f37c0e + undici: ^5.19.1 + checksum: 1280e2ddacec3034ee8e1b398ba80662a6854e184416d3484119e7cf47b69ab2e58b4f1efdf468dcad3e50bdc7bd42b6ee66b95660ffb521efb5f0634ef60fb7 languageName: node linkType: hard