const {shell, ipcRenderer, clipboard} = require("electron"); const {default: YTDlpWrap} = require("yt-dlp-wrap-plus"); const {constants} = require("fs/promises"); const {homedir, platform} = require("os"); const {join} = require("path"); const {mkdirSync, accessSync, promises, existsSync} = require("fs"); const {execSync, spawn} = require("child_process"); const CONSTANTS = { DOM_IDS: { // Main UI PASTE_URL_BTN: "pasteUrl", LOADING_WRAPPER: "loadingWrapper", INCORRECT_MSG: "incorrectMsg", ERROR_BTN: "errorBtn", ERROR_DETAILS: "errorDetails", PATH_DISPLAY: "path", SELECT_LOCATION_BTN: "selectLocation", DOWNLOAD_LIST: "list", CLEAR_BTN: "clearBtn", // Hidden Info Panel HIDDEN_PANEL: "hidden", CLOSE_HIDDEN_BTN: "closeHidden", TITLE_CONTAINER: "title", TITLE_INPUT: "titleName", URL_INPUTS: ".url", AUDIO_PRESENT_SECTION: "audioPresent", QUIT_APP_BTN: "quitAppBtn", // Format Selectors VIDEO_FORMAT_SELECT: "videoFormatSelect", AUDIO_FORMAT_SELECT: "audioFormatSelect", AUDIO_FOR_VIDEO_FORMAT_SELECT: "audioForVideoFormatSelect", // Download Buttons VIDEO_DOWNLOAD_BTN: "videoDownload", AUDIO_DOWNLOAD_BTN: "audioDownload", EXTRACT_BTN: "extractBtn", // Audio Extraction EXTRACT_SELECTION: "extractSelection", EXTRACT_QUALITY_SELECT: "extractQualitySelect", // Advanced Options CUSTOM_ARGS_INPUT: "customArgsInput", // Add this line START_TIME: "min-time", END_TIME: "max-time", MIN_SLIDER: "min-slider", MAX_SLIDER: "max-slider", SLIDER_RANGE_HIGHLIGHT: "range-highlight", SUB_CHECKED: "subChecked", QUIT_CHECKED: "quitChecked", // Popups POPUP_BOX: "popupBox", POPUP_BOX_MAC: "popupBoxMac", POPUP_TEXT: "popupText", POPUP_SVG: "popupSvg", YTDLP_DOWNLOAD_PROGRESS: "ytDlpDownloadProgress", UPDATE_POPUP: "updatePopup", UPDATE_POPUP_PROGRESS: "updateProgress", UPDATE_POPUP_BAR: "progressBarFill", // Menu MENU_ICON: "menuIcon", MENU: "menu", PREFERENCE_WIN: "preferenceWin", ABOUT_WIN: "aboutWin", PLAYLIST_WIN: "playlistWin", HISTORY_WIN: "historyWin", COMPRESSOR_WIN: "compressorWin", }, LOCAL_STORAGE_KEYS: { DOWNLOAD_PATH: "downloadPath", YT_DLP_PATH: "ytdlp", MAX_DOWNLOADS: "maxActiveDownloads", PREFERRED_VIDEO_QUALITY: "preferredVideoQuality", PREFERRED_AUDIO_QUALITY: "preferredAudioQuality", PREFERRED_VIDEO_CODEC: "preferredVideoCodec", SHOW_MORE_FORMATS: "showMoreFormats", BROWSER_COOKIES: "browser", PROXY: "proxy", CONFIG_PATH: "configPath", AUTO_UPDATE: "autoUpdate", CLOSE_TO_TRAY: "closeToTray", YT_DLP_CUSTOM_ARGS: "customYtDlpArgs", }, }; /** * Shorthand for document.getElementById. * @param {string} id The ID of the DOM element. * @returns {HTMLElement | null} */ const $ = (id) => document.getElementById(id); class YtDownloaderApp { constructor() { this.state = { ytDlp: null, ytDlpPath: "", ffmpegPath: "", jsRuntimePath: "", downloadDir: "", maxActiveDownloads: 5, currentDownloads: 0, // Video metadata videoInfo: { title: "", thumbnail: "", duration: 0, extractor_key: "", url: "", }, // Download options downloadOptions: { rangeCmd: "", rangeOption: "", subs: "", subLangs: "", }, // Preferences preferences: { videoQuality: 1080, audioQuality: "", videoCodec: "avc1", showMoreFormats: false, proxy: "", browserForCookies: "", customYtDlpArgs: "", }, downloadControllers: new Map(), downloadedItems: new Set(), downloadQueue: [], }; } /** * Initializes the application, setting up directories, finding executables, * and attaching event listeners. */ async initialize() { await this._initializeTranslations(); this._setupDirectories(); this._configureTray(); this._configureAutoUpdate(); try { this.state.ytDlpPath = await this._findOrDownloadYtDlp(); this.state.ytDlp = new YTDlpWrap(`"${this.state.ytDlpPath}"`); this.state.ffmpegPath = await this._findFfmpeg(); this.state.jsRuntimePath = await this._getJsRuntimePath(); console.log("yt-dlp path:", this.state.ytDlpPath); console.log("ffmpeg path:", this.state.ffmpegPath); console.log("JS runtime path:", this.state.jsRuntimePath); this._loadSettings(); this._addEventListeners(); // Signal to the main process that the renderer is ready for links ipcRenderer.send("ready-for-links"); } catch (error) { console.error("Initialization failed:", error); $(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent = error.message; $(CONSTANTS.DOM_IDS.PASTE_URL_BTN).style.display = "none"; } } /** * Sets up the application's hidden directory and the default download directory. */ _setupDirectories() { const userHomeDir = homedir(); const hiddenDir = join(userHomeDir, ".ytDownloader"); if (!existsSync(hiddenDir)) { try { mkdirSync(hiddenDir, {recursive: true}); } catch (error) { console.log(error); } } let defaultDownloadDir = join(userHomeDir, "Downloads"); if (platform() === "linux") { try { const xdgDownloadDir = execSync("xdg-user-dir DOWNLOAD") .toString() .trim(); if (xdgDownloadDir) { defaultDownloadDir = xdgDownloadDir; } } catch (err) { console.warn("Could not execute xdg-user-dir:", err.message); } } const savedPath = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH ); if (savedPath) { try { accessSync(savedPath, constants.W_OK); this.state.downloadDir = savedPath; } catch { console.warn( `Cannot write to saved path "${savedPath}". Falling back to default.` ); this.state.downloadDir = defaultDownloadDir; localStorage.setItem( CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH, defaultDownloadDir ); } } else { this.state.downloadDir = defaultDownloadDir; } $(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = this.state.downloadDir; if (!existsSync(this.state.downloadDir)) { mkdirSync(this.state.downloadDir, {recursive: true}); } } /** * Checks localStorage to determine if the tray icon should be used. */ _configureTray() { if ( localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CLOSE_TO_TRAY) === "true" ) { console.log("Tray is enabled."); ipcRenderer.send("useTray", true); } } /** * Checks settings to determine if auto-updates should be enabled. */ _configureAutoUpdate() { let autoUpdate = true; if ( localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.AUTO_UPDATE) === "false" ) { autoUpdate = false; } if ( process.windowsStore || process.env.YTDOWNLOADER_AUTO_UPDATES === "0" ) { autoUpdate = false; } ipcRenderer.send("autoUpdate", autoUpdate); } /** * Waits for the i18n module to load and then translates the static page content. */ async _initializeTranslations() { return new Promise((resolve) => { document.addEventListener( "translations-loaded", () => { window.i18n.translatePage(); resolve(); }, {once: true} ); }); } /** * Locates the yt-dlp executable path from various sources or downloads it. * @returns {Promise} A promise that resolves with the path to yt-dlp. */ async _findOrDownloadYtDlp() { const hiddenDir = join(homedir(), ".ytDownloader"); const defaultYtDlpName = platform() === "win32" ? "ytdlp.exe" : "ytdlp"; const defaultYtDlpPath = join(hiddenDir, defaultYtDlpName); const isMacOS = platform() === "darwin"; const isFreeBSD = platform() === "freebsd"; let executablePath = null; // PRIORITY 1: Environment Variable if (process.env.YTDOWNLOADER_YTDLP_PATH) { if (existsSync(process.env.YTDOWNLOADER_YTDLP_PATH)) { executablePath = process.env.YTDOWNLOADER_YTDLP_PATH; } else { throw new Error( "YTDOWNLOADER_YTDLP_PATH is set, but no file exists there." ); } } // PRIORITY 2: macOS homebrew else if (isMacOS) { const possiblePaths = [ "/opt/homebrew/bin/yt-dlp", // Apple Silicon "/usr/local/bin/yt-dlp", // Intel ]; executablePath = possiblePaths.find((p) => existsSync(p)); // If Homebrew check fails, show popup and abort if (!executablePath) { $(CONSTANTS.DOM_IDS.POPUP_BOX_MAC).style.display = "block"; console.warn("Homebrew yt-dlp not found. Prompting user."); return ""; } } // PRIORITY 3: FreeBSD else if (isFreeBSD) { try { executablePath = execSync("which yt-dlp").toString().trim(); } catch { throw new Error( "No yt-dlp found in PATH on FreeBSD. Please install it." ); } } // PRIORITY 4: LocalStorage or Download (Windows/Linux) else { const storedPath = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH ); if (storedPath && existsSync(storedPath)) { executablePath = storedPath; } // Download if missing else { executablePath = await this.ensureYtDlpBinary(defaultYtDlpPath); } } localStorage.setItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH, executablePath ); // Auto update this._runBackgroundUpdate(executablePath, isMacOS); return executablePath; } /** * yt-dlp background update */ _runBackgroundUpdate(executablePath, isMacOS) { try { if (isMacOS) { const brewPaths = [ "/opt/homebrew/bin/brew", "/usr/local/bin/brew", ]; const brewExec = brewPaths.find((p) => existsSync(p)) || "brew"; const brewUpdate = spawn(brewExec, ["upgrade", "yt-dlp"]); brewUpdate.on("error", (err) => console.error("Failed to run 'brew upgrade yt-dlp':", err) ); brewUpdate.stdout.on("data", (data) => console.log("yt-dlp brew update:", data.toString()) ); } else { const updateProc = spawn(executablePath, ["-U"]); updateProc.on("error", (err) => console.error( "Failed to run background yt-dlp update:", err ) ); updateProc.stdout.on("data", (data) => { const output = data.toString(); console.log("yt-dlp update check:", output); if (output.toLowerCase().includes("updating to")) { this._showPopup(i18n.__("updatingYtdlp")); } else if ( output.toLowerCase().includes("updated yt-dlp to") ) { this._showPopup(i18n.__("updatedYtdlp")); } }); } } catch (err) { console.warn("Error initiating background update:", err); } } /** * Checks for the presence of the yt-dlp binary at the default path. * If not found, it attempts to download it from GitHub. * * @param {string} defaultYtDlpPath The expected path to the yt-dlp binary. * @returns {Promise} A promise that resolves with the path to the yt-dlp binary. * @throws {Error} Throws an error if the download fails. */ async ensureYtDlpBinary(defaultYtDlpPath) { try { await promises.access(defaultYtDlpPath); return defaultYtDlpPath; } catch { console.log("yt-dlp not found, downloading..."); $(CONSTANTS.DOM_IDS.POPUP_BOX).style.display = "block"; $(CONSTANTS.DOM_IDS.POPUP_SVG).style.display = "inline"; document.querySelector("#popupBox p").textContent = i18n.__( "downloadingNecessaryFilesWait" ); try { await YTDlpWrap.downloadFromGithub( defaultYtDlpPath, undefined, undefined, (progress, _d, _t) => { $( CONSTANTS.DOM_IDS.YTDLP_DOWNLOAD_PROGRESS ).textContent = i18n.__("progress") + `: ${(progress * 100).toFixed(2)}%`; } ); $(CONSTANTS.DOM_IDS.POPUP_BOX).style.display = "none"; localStorage.setItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH, defaultYtDlpPath ); return defaultYtDlpPath; } catch (downloadError) { $(CONSTANTS.DOM_IDS.YTDLP_DOWNLOAD_PROGRESS).textContent = ""; console.error("Failed to download yt-dlp:", downloadError); document.querySelector("#popupBox p").textContent = i18n.__( "errorFailedFileDownload" ); $(CONSTANTS.DOM_IDS.POPUP_SVG).style.display = "none"; const tryAgainBtn = document.createElement("button"); tryAgainBtn.id = "tryBtn"; tryAgainBtn.textContent = i18n.__("tryAgain"); tryAgainBtn.addEventListener("click", () => { // TODO: Improve it ipcRenderer.send("reload"); }); document.getElementById("popup").appendChild(tryAgainBtn); throw new Error("Failed to download yt-dlp."); } } } /** * Locates the ffmpeg executable path. * @returns {Promise} A promise that resolves with the path to ffmpeg. */ async _findFfmpeg() { // Priority 1: Environment Variable if (process.env.YTDOWNLOADER_FFMPEG_PATH) { if (existsSync(process.env.YTDOWNLOADER_FFMPEG_PATH)) { return process.env.YTDOWNLOADER_FFMPEG_PATH; } throw new Error( "YTDOWNLOADER_FFMPEG_PATH is set, but no file exists there." ); } // Priority 2: System-installed (FreeBSD) if (platform() === "freebsd") { try { return execSync("which ffmpeg").toString().trim(); } catch { throw new Error( "No ffmpeg found in PATH on FreeBSD. App may not work correctly." ); } } // Priority 3: Bundled ffmpeg return join(__dirname, "..", "ffmpeg", "bin"); } /** * Determines the JavaScript runtime path for yt-dlp. * @returns {Promise} A promise that resolves with the JS runtime path. */ async _getJsRuntimePath() { const exeName = "node"; if (process.env.YTDOWNLOADER_NODE_PATH) { if (existsSync(process.env.YTDOWNLOADER_NODE_PATH)) { return `$node:"${process.env.YTDOWNLOADER_NODE_PATH}"`; } return ""; } if (process.env.YTDOWNLOADER_DENO_PATH) { if (existsSync(process.env.YTDOWNLOADER_DENO_PATH)) { return `$deno:"${process.env.YTDOWNLOADER_DENO_PATH}"`; } return ""; } if (platform() === "darwin") { const possiblePaths = [ "/opt/homebrew/bin/deno", "/usr/local/bin/deno", ]; for (const p of possiblePaths) { if (existsSync(p)) { return `deno:"${p}"`; } } console.log("No Deno installation found"); return ""; } let jsRuntimePath = join(__dirname, "..", exeName); if (platform() === "win32") { jsRuntimePath = join(__dirname, "..", `${exeName}.exe`); } if (existsSync(jsRuntimePath)) { return `${exeName}:"${jsRuntimePath}"`; } else { return ""; } } /** * Loads various settings from localStorage into the application state. */ _loadSettings() { const prefs = this.state.preferences; prefs.videoQuality = Number( localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_VIDEO_QUALITY ) ) || 1080; prefs.audioQuality = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_AUDIO_QUALITY ) || ""; prefs.videoCodec = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_VIDEO_CODEC ) || "avc1"; prefs.showMoreFormats = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.SHOW_MORE_FORMATS ) === "true"; prefs.proxy = localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.PROXY) || ""; prefs.browserForCookies = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.BROWSER_COOKIES ) || ""; prefs.customYtDlpArgs = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_CUSTOM_ARGS ) || ""; const maxDownloads = Number( localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.MAX_DOWNLOADS) ); this.state.maxActiveDownloads = maxDownloads >= 1 ? maxDownloads : 5; // Update UI with loaded settings $(CONSTANTS.DOM_IDS.CUSTOM_ARGS_INPUT).value = prefs.customYtDlpArgs; const downloadDir = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH ); if (downloadDir) { this.state.downloadDir = downloadDir; $(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = downloadDir; } } /** * Attaches all necessary event listeners for the UI. */ _addEventListeners() { $(CONSTANTS.DOM_IDS.PASTE_URL_BTN).addEventListener("click", () => this.pasteAndGetInfo() ); document.addEventListener("keydown", (event) => { if ( ((event.ctrlKey && event.key === "v") || (event.metaKey && event.key === "v" && platform() === "darwin")) && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA" ) { $(CONSTANTS.DOM_IDS.PASTE_URL_BTN).classList.add("active"); setTimeout(() => { $(CONSTANTS.DOM_IDS.PASTE_URL_BTN).classList.remove( "active" ); }, 150); this.pasteAndGetInfo(); } }); // Download buttons $(CONSTANTS.DOM_IDS.VIDEO_DOWNLOAD_BTN).addEventListener("click", () => this.handleDownloadRequest("video") ); $(CONSTANTS.DOM_IDS.AUDIO_DOWNLOAD_BTN).addEventListener("click", () => this.handleDownloadRequest("audio") ); $(CONSTANTS.DOM_IDS.EXTRACT_BTN).addEventListener("click", () => this.handleDownloadRequest("extract") ); // UI controls $(CONSTANTS.DOM_IDS.CLOSE_HIDDEN_BTN).addEventListener("click", () => this._hideInfoPanel() ); $(CONSTANTS.DOM_IDS.SELECT_LOCATION_BTN).addEventListener("click", () => ipcRenderer.send("select-location-main", "") ); $(CONSTANTS.DOM_IDS.CLEAR_BTN).addEventListener("click", () => this._clearAllDownloaded() ); // Error details $(CONSTANTS.DOM_IDS.ERROR_DETAILS).addEventListener("click", (e) => { // @ts-ignore clipboard.writeText(e.target.innerText); this._showPopup(i18n.__("copiedText"), false); }); $(CONSTANTS.DOM_IDS.QUIT_APP_BTN).addEventListener("click", () => { ipcRenderer.send("quit", "quit"); }); // IPC listeners ipcRenderer.on("link", (event, text) => this.getInfo(text)); ipcRenderer.on("downloadPath", (event, downloadPath) => { try { accessSync(downloadPath[0], constants.W_OK); const newPath = downloadPath[0]; $(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = newPath; this.state.downloadDir = newPath; } catch (error) { console.log(error); this._showPopup(i18n.__("unableToAccessDir"), true); } }); ipcRenderer.on("download-progress", (_event, percent) => { if (percent) { const popup = $(CONSTANTS.DOM_IDS.UPDATE_POPUP); const textEl = $(CONSTANTS.DOM_IDS.UPDATE_POPUP_PROGRESS); const barEl = $(CONSTANTS.DOM_IDS.UPDATE_POPUP_BAR); popup.style.display = "flex"; textEl.textContent = `${percent.toFixed(1)}%`; barEl.style.width = `${percent}%`; } }); ipcRenderer.on("update-downloaded", (_event, _) => { $(CONSTANTS.DOM_IDS.UPDATE_POPUP).style.display = "none"; }); // Menu Listeners const menuMapping = { [CONSTANTS.DOM_IDS.PREFERENCE_WIN]: "/preferences.html", [CONSTANTS.DOM_IDS.ABOUT_WIN]: "/about.html", [CONSTANTS.DOM_IDS.HISTORY_WIN]: "/history.html", }; const windowMapping = { [CONSTANTS.DOM_IDS.PLAYLIST_WIN]: "/playlist.html", [CONSTANTS.DOM_IDS.COMPRESSOR_WIN]: "/compressor.html", }; Object.entries(menuMapping).forEach(([id, page]) => { $(id)?.addEventListener("click", () => { this._closeMenu(); ipcRenderer.send("load-page", join(__dirname, page)); }); }); Object.entries(windowMapping).forEach(([id, page]) => { $(id)?.addEventListener("click", () => { this._closeMenu(); ipcRenderer.send("load-win", join(__dirname, page)); }); }); const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER); const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER); minSlider.addEventListener("input", () => this._updateSliderUI(minSlider) ); maxSlider.addEventListener("input", () => this._updateSliderUI(maxSlider) ); $(CONSTANTS.DOM_IDS.START_TIME).addEventListener( "change", this._handleTimeInputChange ); $(CONSTANTS.DOM_IDS.END_TIME).addEventListener( "change", this._handleTimeInputChange ); this._updateSliderUI(null); } // --- Public Methods --- /** * Pastes URL from clipboard and initiates fetching video info. */ pasteAndGetInfo() { this.getInfo(clipboard.readText()); } /** * Fetches video metadata from a given URL. * @param {string} url The video URL. */ async getInfo(url) { this._loadSettings(); this._defaultVideoToggle(); this._resetUIForNewLink(); this.state.videoInfo.url = url; try { const metadata = await this._fetchVideoMetadata(url); console.log(metadata); const durationInt = metadata.duration == null ? null : Math.ceil(metadata.duration); this.state.videoInfo = { ...this.state.videoInfo, id: metadata.id, title: metadata.title, thumbnail: metadata.thumbnail, duration: durationInt, extractor_key: metadata.extractor_key, }; this.setVideoLength(durationInt); this._populateFormatSelectors(metadata.formats || []); this._displayInfoPanel(); } catch (error) { if ( error.message.includes("js-runtimes") && error.message.includes("no such option") ) { this._showError(i18n.__("ytDlpUpdateRequired"), url); } else { this._showError(error.message, url); } } finally { $(CONSTANTS.DOM_IDS.LOADING_WRAPPER).style.display = "none"; } } /** * Handles a download request, either starting it immediately or queuing it. * @param {'video' | 'audio' | 'extract'} type The type of download. */ handleDownloadRequest(type) { this._updateDownloadOptionsFromUI(); const downloadJob = { type, url: this.state.videoInfo.url, title: this.state.videoInfo.title, thumbnail: this.state.videoInfo.thumbnail, options: {...this.state.downloadOptions}, // Capture UI values at the moment of click uiSnapshot: { videoFormat: $(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT).value, audioForVideoFormat: $( CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT ).value, audioFormat: $(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT).value, extractFormat: $(CONSTANTS.DOM_IDS.EXTRACT_SELECTION).value, extractQuality: $(CONSTANTS.DOM_IDS.EXTRACT_QUALITY_SELECT) .value, }, }; if (this.state.currentDownloads < this.state.maxActiveDownloads) { this._startDownload(downloadJob); } else { this._queueDownload(downloadJob); } this._hideInfoPanel(); } /** * Executes yt-dlp to get video metadata in JSON format. * @param {string} url The video URL. * @returns {Promise} A promise that resolves with the parsed JSON metadata. */ _fetchVideoMetadata(url) { return new Promise((resolve, reject) => { const {proxy, browserForCookies, configPath} = this.state.preferences; const args = [ "-j", "--no-playlist", "--no-warnings", proxy ? "--proxy" : "", proxy, browserForCookies ? "--cookies-from-browser" : "", browserForCookies, this.state.jsRuntimePath ? `--no-js-runtimes --js-runtime ${this.state.jsRuntimePath}` : "", configPath ? "--config-location" : "", configPath ? `"${configPath}"` : "", `"${url}"`, ].filter(Boolean); const process = this.state.ytDlp.exec(args, {shell: true}); let stdout = ""; let stderr = ""; process.ytDlpProcess.stdout.on("data", (data) => { stdout += data; }); process.ytDlpProcess.stderr.on("data", (data) => (stderr += data)); process.on("close", () => { if (stdout) { try { resolve(JSON.parse(stdout)); } catch (e) { reject( new Error( "Failed to parse yt-dlp JSON output: " + (stderr || e.message) ) ); } } else { reject( new Error( stderr || `yt-dlp exited with a non-zero code.` ) ); } }); process.on("error", (err) => reject(err)); }); } /** * Starts the download process for a given job. * @param {object} job The download job object. */ _startDownload(job) { this.state.currentDownloads++; const randomId = "item_" + Math.random().toString(36).substring(2, 12); const {downloadArgs, finalFilename, finalExt} = this._prepareDownloadArgs(job); this._createDownloadUI(randomId, job); const controller = new AbortController(); this.state.downloadControllers.set(randomId, controller); const downloadProcess = this.state.ytDlp.exec(downloadArgs, { shell: true, detached: false, signal: controller.signal, }); console.log( "Spawned yt-dlp with args:", downloadProcess.ytDlpProcess.spawnargs.join(" ") ); // Attach event listeners downloadProcess .on("progress", (progress) => { this._updateProgressUI(randomId, progress); }) .once("ytDlpEvent", () => { const el = $(`${randomId}_prog`); if (el) el.textContent = i18n.__("downloading"); }) // .on("ytDlpEvent", (eventType, eventData) => { // console.log(eventData) // }) .once("close", (code) => { this._handleDownloadCompletion( code, randomId, finalFilename, finalExt, job.thumbnail ); }) .once("error", (error) => { this.state.downloadedItems.add(randomId); this._updateClearAllButton(); this._handleDownloadError(error, randomId); }); } /** * Queues a download job if the maximum number of active downloads is reached. * @param {object} job The download job object. */ _queueDownload(job) { const randomId = "queue_" + Math.random().toString(36).substring(2, 12); this.state.downloadQueue.push({...job, queueId: randomId}); const itemHTML = `
thumbnail ${i18n.__( job.type === "video" ? "video" : "audio" )}
${job.title}

${i18n.__("preparing")}

`; $(CONSTANTS.DOM_IDS.DOWNLOAD_LIST).insertAdjacentHTML( "beforeend", itemHTML ); } /** * Checks the queue and starts the next download if a slot is available. */ _processQueue() { if ( this.state.downloadQueue.length > 0 && this.state.currentDownloads < this.state.maxActiveDownloads ) { const nextJob = this.state.downloadQueue.shift(); // Remove the pending UI element $(nextJob.queueId)?.remove(); this._startDownload(nextJob); } } /** * Prepares the command-line arguments for yt-dlp based on the download job. * @param {object} job The download job object. * @returns {{downloadArgs: string[], finalFilename: string, finalExt: string}} */ _prepareDownloadArgs(job) { const {type, url, title, options, uiSnapshot} = job; const {rangeOption, rangeCmd, subs, subLangs} = options; const {proxy, browserForCookies, configPath} = this.state.preferences; let format_id, ext, audioForVideoFormat_id, audioFormat; if (type === "video") { const [videoFid, videoExt, _, videoCodec] = uiSnapshot.videoFormat.split("|"); const [audioFid, audioExt] = uiSnapshot.audioForVideoFormat.split("|"); format_id = videoFid; audioForVideoFormat_id = audioFid; const finalAudioExt = audioExt === "webm" ? "opus" : audioExt; ext = videoExt; if (videoExt === "mp4" && finalAudioExt === "opus") { if (videoCodec.includes("avc")) ext = "mkv"; else if (videoCodec.includes("av01")) ext = "webm"; } else if ( videoExt === "webm" && ["m4a", "mp4"].includes(finalAudioExt) ) { ext = "mkv"; } audioFormat = audioForVideoFormat_id === "none" ? "" : `+${audioForVideoFormat_id}`; } else if (type === "audio") { [format_id, ext] = uiSnapshot.audioFormat.split("|"); ext = ext === "webm" ? "opus" : ext; } else { // type === 'extract' ext = {alac: "m4a"}[uiSnapshot.extractFormat] || uiSnapshot.extractFormat; } const invalidChars = platform() === "win32" ? /[<>:"/\\|?*[\]`#]/g : /["/`#]/g; let finalFilename = title .replace(invalidChars, "") .trim() .slice(0, 100); if (finalFilename.startsWith(".")) { finalFilename = finalFilename.substring(1); } if (rangeCmd) { let rangeTxt = rangeCmd.replace("*", ""); if (platform() === "win32") rangeTxt = rangeTxt.replace(/:/g, "_"); finalFilename += ` [${rangeTxt}]`; } const outputPath = `"${join( this.state.downloadDir, `${finalFilename}.${ext}` )}"`; const baseArgs = [ "--no-playlist", "--no-mtime", browserForCookies ? "--cookies-from-browser" : "", browserForCookies, proxy ? "--proxy" : "", proxy, configPath ? "--config-location" : "", configPath ? `"${configPath}"` : "", "--ffmpeg-location", `"${this.state.ffmpegPath}"`, this.state.jsRuntimePath ? `--no-js-runtimes --js-runtime ${this.state.jsRuntimePath}` : "", ].filter(Boolean); if (type === "audio") { if (ext === "m4a" || ext === "mp3" || ext === "mp4") { baseArgs.unshift("--embed-thumbnail"); } } else if (type === "extract") { if (ext === "mp3" || ext === "m4a") { baseArgs.unshift("--embed-thumbnail"); } } let downloadArgs; if (type === "extract") { downloadArgs = [ "-x", "--audio-format", uiSnapshot.extractFormat, "--audio-quality", uiSnapshot.extractQuality, "-o", outputPath, ...baseArgs, ]; } else { const formatString = type === "video" ? `${format_id}${audioFormat}` : format_id; downloadArgs = ["-f", formatString, "-o", outputPath, ...baseArgs]; } if (subs) downloadArgs.push(subs); if (subLangs) downloadArgs.push(subLangs); if (rangeOption) downloadArgs.push(rangeOption, rangeCmd); const customArgsString = $( CONSTANTS.DOM_IDS.CUSTOM_ARGS_INPUT ).value.trim(); if (customArgsString) { const customArgs = customArgsString.split(/\s+/); downloadArgs.push(...customArgs); } downloadArgs.push(`"${url}"`); return {downloadArgs, finalFilename, finalExt: ext}; } /** * Handles the completion of a download process. */ _handleDownloadCompletion(code, randomId, filename, ext, thumbnail) { this.state.currentDownloads--; this.state.downloadControllers.delete(randomId); if (code === 0) { this._showDownloadSuccessUI(randomId, filename, ext, thumbnail); this.state.downloadedItems.add(randomId); this._updateClearAllButton(); } else if (code !== null) { // code is null if aborted, so only show error if it's a real exit code this._handleDownloadError( new Error(`Download process exited with code ${code}.`), randomId ); } this._processQueue(); if ($(CONSTANTS.DOM_IDS.QUIT_CHECKED).checked) { ipcRenderer.send("quit", "quit"); } } /** * Handles an error during the download process. */ _handleDownloadError(error, randomId) { if ( error.name === "AbortError" || error.message.includes("AbortError") ) { console.log(`Download ${randomId} was aborted.`); this.state.currentDownloads = Math.max( 0, this.state.currentDownloads - 1 ); this.state.downloadControllers.delete(randomId); this._processQueue(); return; // Don't treat user cancellation as an error } this.state.currentDownloads--; this.state.downloadControllers.delete(randomId); console.error("Download Error:", error); const progressEl = $(`${randomId}_prog`); if (progressEl) { progressEl.textContent = i18n.__("errorHoverForDetails"); progressEl.title = error.message; } this._processQueue(); } /** * Updates the download options state from the UI elements. */ _updateDownloadOptionsFromUI() { const startTime = $(CONSTANTS.DOM_IDS.START_TIME).value; const endTime = $(CONSTANTS.DOM_IDS.END_TIME).value; const duration = this.state.videoInfo.duration; const startSeconds = this.parseTime(startTime); const endSeconds = this.parseTime(endTime); if ( startSeconds === 0 && (endSeconds === duration || endSeconds === 0) ) { this.state.downloadOptions.rangeCmd = ""; this.state.downloadOptions.rangeOption = ""; } else { const start = startTime || "0"; const end = endTime || "inf"; this.state.downloadOptions.rangeCmd = `*${start}-${end}`; this.state.downloadOptions.rangeOption = "--download-sections"; } if ($(CONSTANTS.DOM_IDS.SUB_CHECKED).checked) { this.state.downloadOptions.subs = "--write-subs"; this.state.downloadOptions.subLangs = "--sub-langs all"; } else { this.state.downloadOptions.subs = ""; this.state.downloadOptions.subLangs = ""; } } /** * Resets the UI state for a new link. */ _resetUIForNewLink() { this._hideInfoPanel(); $(CONSTANTS.DOM_IDS.LOADING_WRAPPER).style.display = "flex"; $(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent = ""; $(CONSTANTS.DOM_IDS.ERROR_BTN).style.display = "none"; $(CONSTANTS.DOM_IDS.ERROR_DETAILS).style.display = "none"; $(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT).innerHTML = ""; $(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT).innerHTML = ""; const noAudioTxt = i18n.__("noAudio"); $( CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT ).innerHTML = ``; } /** * Populates the video and audio format