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, cpSync, copyFileSync, } = 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", YT_DLP_SOURCE: "ytdlpSource", }, // yt-dlp source selectable in preferences. // "nightly": app-managed standalone binary kept on the nightly channel. // "system": use the yt-dlp found in PATH (managed by apt/pip/brew/etc.). YT_DLP_SOURCE: { NIGHTLY: "nightly", SYSTEM: "system", }, }; /** * 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: "", channel: "", 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: "", videoOutputTemplate: "%(title)s.%(ext)s", audioOutputTemplate: "%(title)s.%(ext)s", }, 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._ensureFfmpegLibsLoadable(this.state.ffmpegPath); 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:", this.state.jsRuntimePath); this._addEventListeners(); } 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: User-selected source (Windows/Linux) else { // Source is chosen in preferences; defaults to the nightly, // app-managed binary so YouTube fixes arrive fastest. const source = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_SOURCE, ) || CONSTANTS.YT_DLP_SOURCE.NIGHTLY; // "system": use yt-dlp from PATH (apt/pip/etc.). Falls back to the // managed binary if nothing is found in PATH. if (source === CONSTANTS.YT_DLP_SOURCE.SYSTEM) { try { let systemPath; if (platform() === "win32") { systemPath = execSync("where yt-dlp") .toString() .split(/\r?\n/)[0] .trim(); } else { systemPath = execSync("command -v yt-dlp") .toString() .trim(); } if (systemPath && existsSync(systemPath)) { executablePath = systemPath; } } catch { // Not found in PATH; fall back to the managed binary. } } // "nightly" (default), or "system" fallback: use the app-managed // binary. A stored path is reused; otherwise it is downloaded. if (!executablePath) { const storedPath = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH, ); if ( storedPath && existsSync(storedPath) && storedPath.startsWith(hiddenDir) ) { 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 { // Only self-update binaries we manage in ~/.ytDownloader. // A system yt-dlp (apt/pip/etc.) rejects `-U` and is updated // by the system package manager instead. const hiddenDir = join(homedir(), ".ytDownloader"); if (!executablePath.startsWith(hiddenDir)) { console.log( "Using system yt-dlp; skipping self-update (-U).", ); return; } const releaseChannel = "nightly"; const updateProc = spawn(executablePath, [ "--update-to", releaseChannel, ]); 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 const bundledDir = join(__dirname, "..", "ffmpeg"); const targetDir = join(homedir(), ".ytDownloader", "ffmpeg"); const isWin = platform() === "win32"; const ffmpegName = isWin ? "ffmpeg.exe" : "ffmpeg"; const targetFfmpegFile = join(targetDir, "bin", ffmpegName); // Check if the folder has already been copied if (!existsSync(targetFfmpegFile)) { if (existsSync(bundledDir)) { try { cpSync(bundledDir, targetDir, {recursive: true}); } catch { console.error("Failed to copy bundled ffmpeg."); return ""; } } else { return ""; } } return join(targetDir, "bin"); } /** * The bundled Linux ffmpeg is dynamically linked and ships its shared * libraries in a sibling "lib" directory, but its RPATH is broken, so it * cannot find them on its own. yt-dlp spawns ffmpeg as a child process, so * we expose that lib directory via LD_LIBRARY_PATH (inherited by children). * Without this, audio extraction fails with "ffprobe and ffmpeg not found". * @param {string} ffmpegPath Path to the bundled ffmpeg "bin" directory. */ _ensureFfmpegLibsLoadable(ffmpegPath) { if (platform() !== "linux" || !ffmpegPath) { return; } const libDir = join(ffmpegPath, "..", "lib"); if (!existsSync(libDir)) { return; } const current = process.env.LD_LIBRARY_PATH; if (current) { if (!current.split(":").includes(libDir)) { process.env.LD_LIBRARY_PATH = `${libDir}:${current}`; } } else { process.env.LD_LIBRARY_PATH = libDir; } } /** * Determines the JavaScript runtime path for yt-dlp. * @returns {Promise} A promise that resolves with the JS runtime path. */ async _getJsRuntimePath() { const exeName = "node"; // Priority 1: Environment Variable (Node) if (process.env.YTDOWNLOADER_NODE_PATH) { if (existsSync(process.env.YTDOWNLOADER_NODE_PATH)) { return `$node:${process.env.YTDOWNLOADER_NODE_PATH}`; } return ""; } // Priority 2: Environment Variable (Deno) if (process.env.YTDOWNLOADER_DENO_PATH) { if (existsSync(process.env.YTDOWNLOADER_DENO_PATH)) { return `$deno:${process.env.YTDOWNLOADER_DENO_PATH}`; } return ""; } // Priority 3: System-installed Deno (macOS Fallback) 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 ""; } // Priority 4: Bundled Node Runtime const isWin = platform() === "win32"; const nodeName = isWin ? "node.exe" : "node"; const bundledNodePath = join(__dirname, "..", nodeName); const targetDir = join(homedir(), ".ytDownloader"); const targetNodeFile = join(targetDir, nodeName); // Check if folder has already been copied if (existsSync(targetNodeFile)) { return `${exeName}:${targetNodeFile}`; } // Copy to .ytDownloader if (existsSync(bundledNodePath)) { if (!existsSync(targetDir)) { mkdirSync(targetDir, {recursive: true}); } try { copyFileSync(bundledNodePath, targetNodeFile); } catch { console.error("Failed to copy bundled Node runtime."); return ""; } return `${exeName}:${targetNodeFile}`; } return ""; } /** * Loads various settings from localStorage into the application state. */ async _loadSettings(url) { 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) || ""; if (!prefs.proxy) { try { const systemProxy = await ipcRenderer.invoke( "get-system-proxy", url, ); if (systemProxy) { prefs.proxy = systemProxy; console.log("Using system proxy:", systemProxy); } } catch (err) { console.error("Failed to get system proxy:", err); } } prefs.browserForCookies = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.BROWSER_COOKIES, ) || ""; prefs.customYtDlpArgs = localStorage.getItem( CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_CUSTOM_ARGS, ) || ""; prefs.configPath = localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CONFIG_PATH) || ""; prefs.videoOutputTemplate = localStorage.getItem("filenameTemplateVideo") || "%(title)s.%(ext)s"; prefs.audioOutputTemplate = localStorage.getItem("filenameTemplateAudio") || "%(title)s.%(ext)s"; 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) { let safeUrl; try { safeUrl = this.validateUrl(url); } catch { this._showError(i18n.__("invalidUrl"), url); return; } await this._loadSettings(safeUrl); this._defaultVideoToggle(); this._resetUIForNewLink(); this.state.videoInfo.url = safeUrl; try { const metadata = await this._fetchVideoMetadata(safeUrl); 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, channel: metadata.channel, thumbnail: metadata.thumbnail, duration: durationInt, extractor_key: metadata.extractor_key, }; this.setVideoLength(durationInt); this._populateFormatSelectors(metadata.formats || []); this._displayInfoPanel(); } catch (error) { console.log(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, channel: this.state.videoInfo.channel, 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] : []), "--", url, ]; const process = this.state.ytDlp.exec(args, {shell: false}); window.addEventListener("beforeunload", () => { if (process && !process.killed) { process.kill(); } }); console.log( "Spawned yt-dlp with args:", process.ytDlpProcess.spawnargs.join(" "), ); 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, tempFilePath} = 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: false, signal: controller.signal, }); window.addEventListener("beforeunload", () => { if (downloadProcess && !downloadProcess.killed) { downloadProcess.kill(); } }); console.log( "Spawned yt-dlp with args:", downloadProcess.ytDlpProcess.spawnargs.join(" "), ); let actualFilePath = ""; downloadProcess.ytDlpProcess.stdout.on("data", (data) => { const str = data.toString(); const destMatch = str.match(/Destination:\s+([^\r\n]+)/); if (destMatch) actualFilePath = destMatch[1] .trim() .replace(/(^["'])|(["']$)/g, ""); const mergeMatch = str.match(/Merging formats into\s+([^\r\n]+)/); if (mergeMatch) actualFilePath = mergeMatch[1] .trim() .replace(/(^["'])|(["']$)/g, ""); const existMatch = str.match( /\[download\]\s+([^\r\n]+)\s+has already been downloaded/, ); if (existMatch) actualFilePath = existMatch[1] .trim() .replace(/(^["'])|(["']$)/g, ""); }); downloadProcess .on("progress", (progress) => { this._updateProgressUI(randomId, progress); }) .once("ytDlpEvent", () => { const el = $(`${randomId}_prog`); if (el) el.textContent = i18n.__("downloading"); }) .once("close", (code) => { if (existsSync(tempFilePath)) { try { const fileContent = require("fs") .readFileSync(tempFilePath, "utf-8") .trim(); if (fileContent) { actualFilePath = fileContent; } require("fs").unlinkSync(tempFilePath); } catch (e) { console.error("Error reading temp file:", e); } } this._handleDownloadCompletion( code, randomId, actualFilePath, job.thumbnail, ); }) .once("error", (error) => { if (existsSync(tempFilePath)) { try { require("fs").unlinkSync(tempFilePath); } catch (e) {} } 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}
${job.channel}

${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[], tempFilePath: string}} */ _prepareDownloadArgs(job) { const {type, url, title, options, uiSnapshot} = job; const {rangeOption, rangeCmd, subs, subLangs} = options; const { proxy, browserForCookies, configPath, videoOutputTemplate, audioOutputTemplate, } = this.state.preferences; let format_id, ext, audioForVideoFormat_id, audioFormat; let template = videoOutputTemplate; 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; template = audioOutputTemplate; } else { ext = {alac: "m4a"}[uiSnapshot.extractFormat] || uiSnapshot.extractFormat; template = audioOutputTemplate; } if (rangeCmd) { let rangeTxt = rangeCmd.replace("*", ""); if (platform() === "win32") rangeTxt = rangeTxt.replace(/:/g, "_"); if (template.includes(".%(ext)s")) { template = template.replace( ".%(ext)s", ` [${rangeTxt}].%(ext)s`, ); } else { template += ` [${rangeTxt}]`; } } const outputArgs = ["-P", this.state.downloadDir, "-o", template]; const baseArgs = [ "--no-playlist", "--no-mtime", ...(browserForCookies ? ["--cookies-from-browser", browserForCookies] : []), ...(proxy ? ["--proxy", proxy] : []), ...(configPath ? ["--config-location", configPath] : []), "--ffmpeg-location", this.state.ffmpegPath, ...(this.state.jsRuntimePath ? ["--no-js-runtimes", "--js-runtime", this.state.jsRuntimePath] : []), ]; 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, ...outputArgs, ...baseArgs, ]; } else { const formatString = type === "video" ? `${format_id}${audioFormat}` : format_id; downloadArgs = ["-f", formatString, ...outputArgs, ...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); } const randomId = "item_" + Math.random().toString(36).substring(2, 12); const sanitizedTitle = (job.title || "Unknown Title").replace( /"/g, "'", ); downloadArgs.push( "--replace-in-metadata", "title", "^.*$", sanitizedTitle, ); // Create a unique temporary file path to capture the exact filename from yt-dlp const tmpDir = require("os").tmpdir(); const tempFilePath = join(tmpDir, `ytdlp_path_${randomId}.txt`); // Tell yt-dlp to output the absolute final file path directly to the temp file downloadArgs.push( "--print-to-file", "after_move:filepath", tempFilePath, ); downloadArgs.push(url); return {downloadArgs, tempFilePath}; } /** * Handles the completion of a download process. */ _handleDownloadCompletion(code, randomId, actualFilePath, thumbnail) { this.state.currentDownloads--; this.state.downloadControllers.delete(randomId); if (code === 0) { this._showDownloadSuccessUI(randomId, actualFilePath, thumbnail); this.state.downloadedItems.add(randomId); this._updateClearAllButton(); } else if (code !== null) { 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