You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ytDownloader/src/renderer.js

2308 lines
60 KiB
JavaScript

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<string>} 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<string>} 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<string>} 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<string>} 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<object>} 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 = `
<div class="item" id="${randomId}">
<div class="itemIconBox">
<img src="${
job.thumbnail || "../assets/images/thumb.png"
}" alt="thumbnail" class="itemIcon" crossorigin="anonymous">
<span class="itemType">${i18n.__(
job.type === "video" ? "video" : "audio",
)}</span>
</div>
<div class="itemBody">
<div class="itemTitle">${job.title}</div>
<div class="itemChannel">${job.channel}</div>
<p>${i18n.__("preparing")}</p>
</div>
</div>`;
$(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 =
`<option value="none|none">${noAudioTxt}</option>`;
}
/**
* Populates the video and audio format <select> elements.
* @param {Array} formats The formats array from yt-dlp metadata.
*/
_populateFormatSelectors(formats) {
const videoSelectId = CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT;
const audioSelectId = CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT;
const audioForVideoSelectId =
CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT;
const {videoQuality, videoCodec, showMoreFormats} =
this.state.preferences;
let bestMatchHeight = 0;
const speakerIconSvg = `<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`;
// Convert en -> English
const langFormatter = new Intl.DisplayNames(
[navigator.language || "en"],
{type: "language"},
);
const getLanguageName = (code) => {
if (!code) return "";
try {
// Split cases like "en-US"
const baseCode = code.split("-")[0];
const name = langFormatter.of(baseCode);
return name
? name.charAt(0).toUpperCase() + name.slice(1)
: code;
} catch (e) {
return code;
}
};
// Find the ideal match height boundary
formats.forEach((f) => {
if (
f.height &&
f.height <= videoQuality &&
f.height > bestMatchHeight &&
f.video_ext !== "none"
) {
bestMatchHeight = f.height;
}
});
if (bestMatchHeight === 0 && formats.length > 0) {
bestMatchHeight = Math.max(
...formats.filter((f) => f.height).map((f) => f.height),
);
}
const availableCodecs = new Set(
formats
.filter((f) => f.height === bestMatchHeight && f.vcodec)
.map((f) => f.vcodec.split(".")[0]),
);
const finalCodec = availableCodecs.has(videoCodec)
? videoCodec
: [...availableCodecs].pop();
let isAVideoSelected = false;
const videoOptions = [];
const audioOptions = [];
formats.forEach((format) => {
let sizeInMB = null;
let isApprox = false;
if (format.filesize) {
sizeInMB = format.filesize / 1000000;
} else if (format.filesize_approx) {
sizeInMB = format.filesize_approx / 1000000;
isApprox = true;
} else if (this.state.videoInfo.duration && format.tbr) {
sizeInMB = (this.state.videoInfo.duration * format.tbr) / 8192;
isApprox = true;
}
const displaySize = sizeInMB
? `${isApprox ? "~" : ""}${sizeInMB.toFixed(2)} MB`
: i18n.__("unknownSize");
if (format.video_ext !== "none" && format.vcodec !== "none") {
if (
!showMoreFormats &&
(format.ext === "webm" || format.vcodec?.startsWith("vp"))
) {
return;
}
let isSelected = false;
if (
!isAVideoSelected &&
format.height === bestMatchHeight &&
format.vcodec?.startsWith(finalCodec)
) {
isSelected = true;
isAVideoSelected = true;
}
const quality = `${format.height || "???"}p${format.fps === 60 ? "60" : ""}`;
const vcodecText = format.vcodec?.split(".")[0] || "";
let audioMarkup = `<div class="audio-placeholder"></div>`;
if (format.acodec !== "none") {
const langName = getLanguageName(format.language);
const langSpan = langName
? `<span class="lang-text">${langName}</span>`
: "";
audioMarkup = `
<div class="audio-indicator">
${speakerIconSvg}
${langSpan}
</div>
`;
}
const codecHtml = showMoreFormats
? `<span class="codec-text">${vcodecText}</span>`
: "";
const gridClass = showMoreFormats
? "video-grid-extended"
: "video-grid-compact";
const optionTextFallback = showMoreFormats
? `${quality} ${format.ext} ${vcodecText} ${displaySize}`
: `${quality} ${format.ext} ${displaySize}`;
const htmlContent = `
<div class="modern-option-row ${gridClass}">
<span class="main-text">${quality}</span>
<span class="badge badge-format">${format.ext}</span>
${codecHtml}
<span class="size-text">${displaySize}</span>
${audioMarkup}
</div>
`;
videoOptions.push({
text: optionTextFallback,
value: `${format.format_id}|${format.ext}|${format.height}|${format.vcodec}`,
selected: isSelected,
html: htmlContent,
});
// PROCESS AUDIO ONLY CHANNELS
} else if (
format.acodec !== "none" &&
format.video_ext === "none"
) {
if (!showMoreFormats && format.ext === "webm") return;
const audioExt = format.ext === "webm" ? "opus" : format.ext;
const formatNote =
i18n.__(format.format_note) || i18n.__("unknownQuality");
// HTML for Audio Grid
const htmlContent = `
<div class="modern-option-row audio-grid">
<span class="main-text">${formatNote}</span>
<span class="badge badge-format">${audioExt}</span>
<span class="size-text">${displaySize}</span>
</div>
`;
audioOptions.push({
text: `${formatNote} ${audioExt} ${displaySize}`,
value: `${format.format_id}|${audioExt}`,
html: htmlContent,
});
}
});
const hasAudioTrack = formats.some(
(f) =>
f.acodec !== "none" &&
f.acodec !== undefined &&
f.vcodec === "none",
);
const audioSection = $(CONSTANTS.DOM_IDS.AUDIO_PRESENT_SECTION);
if (audioSection) {
audioSection.style.display = hasAudioTrack ? "block" : "none";
}
const videoSelectEl = $(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT);
const audioSelectEl = $(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT);
const audioForVideoSelectEl = $(
CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT,
);
const mountSlimSelect = (domElement, optionsData) => {
if (!domElement) return;
if (domElement.slim) {
domElement.slim.destroy();
}
domElement.slim = new SlimSelect({
select: domElement,
data: optionsData,
settings: {
showSearch: true,
contentLocation: document.body,
},
events: {
afterOpen: () => {
document
.querySelectorAll(".ss-option.ss-selected")
.forEach((el) => {
el.addEventListener(
"click",
() => {
domElement.slim.close();
},
{once: true},
);
});
},
},
});
};
if (videoOptions.length > 0) {
mountSlimSelect(videoSelectEl, videoOptions);
}
if (audioOptions.length > 0) {
mountSlimSelect(audioSelectEl, audioOptions);
mountSlimSelect(
audioForVideoSelectEl,
JSON.parse(JSON.stringify(audioOptions)),
);
}
const audioForVideoOptions = JSON.parse(JSON.stringify(audioOptions));
const noAudioTxt = i18n.__("noAudio") || "No Audio";
audioForVideoOptions.push({
text: noAudioTxt,
value: "none|none",
html: `
<div class="modern-option-row audio-grid">
<span class="main-text">${noAudioTxt}</span>
</div>
`,
});
mountSlimSelect(audioForVideoSelectEl, audioForVideoOptions);
}
/**
* Shows the hidden panel with video information.
*/
_displayInfoPanel() {
const info = this.state.videoInfo;
const titleContainer = $(CONSTANTS.DOM_IDS.TITLE_CONTAINER);
titleContainer.innerHTML = ""; // Clear previous content
titleContainer.append(
Object.assign(document.createElement("b"), {
textContent: i18n.__("title") + ": ",
}),
Object.assign(document.createElement("input"), {
className: "title",
id: CONSTANTS.DOM_IDS.TITLE_INPUT,
type: "text",
value: `${info.title}`,
onchange: (e) => (this.state.videoInfo.title = e.target.value),
}),
);
document
.querySelectorAll(CONSTANTS.DOM_IDS.URL_INPUTS)
.forEach((el) => {
el.value = info.url;
});
const hiddenPanel = $(CONSTANTS.DOM_IDS.HIDDEN_PANEL);
hiddenPanel.style.display = "inline-block";
hiddenPanel.classList.add("scaleUp");
}
/**
* Creates the initial UI element for a new download.
*/
_createDownloadUI(randomId, job) {
const itemHTML = `
<div class="item" id="${randomId}">
<div class="itemIconBox">
<img src="${
job.thumbnail || "../assets/images/thumb.png"
}" alt="thumbnail" class="itemIcon" crossorigin="anonymous">
<span class="itemType">${i18n.__(
job.type === "video" ? "video" : "audio",
)}</span>
</div>
<img src="../assets/images/close.png" class="itemClose" id="${randomId}_close">
<div class="itemBody">
<div class="itemTitle">${job.title}</div>
<div class="itemChannel">${job.channel}</div>
<div class="speedContainer">
<span class="itemSpeed" id="${randomId}_speed"></span>
</div>
<div id="${randomId}_prog" class="itemProgress">${i18n.__(
"preparing",
)}</div>
<button id="${randomId}_openBtn" class="openFileBtn"><img class="btnIcon" src="../assets/images/external-link.png"/>Open file</button>
</div>
</div>`;
$(CONSTANTS.DOM_IDS.DOWNLOAD_LIST).insertAdjacentHTML(
"beforeend",
itemHTML,
);
$(`${randomId}_close`).addEventListener("click", () =>
this._cancelDownload(randomId),
);
}
/**
* Updates the progress bar and speed for a download item.
*/
_updateProgressUI(randomId, progress) {
const speedEl = $(`${randomId}_speed`);
const progEl = $(`${randomId}_prog`);
if (!speedEl || !progEl) return;
let fillEl = progEl.querySelector(".custom-progress-fill");
if (!fillEl) {
progEl.innerHTML = "";
const bar = document.createElement("div");
bar.className = "custom-progress";
fillEl = document.createElement("div");
fillEl.className = "custom-progress-fill";
bar.appendChild(fillEl);
progEl.appendChild(bar);
}
if (progress.percent === 100) {
fillEl.style.width = progress.percent + "%";
speedEl.textContent = "";
progEl.textContent = i18n.__("processing");
ipcRenderer.send("progress", 0);
return;
}
speedEl.textContent = `${i18n.__("speed")}: ${
progress.currentSpeed || "0 B/s"
}`;
fillEl.style.width = progress.percent + "%";
ipcRenderer.send("progress", progress.percent / 100);
}
/**
* Updates a download item's UI to show it has completed successfully.
*/
_showDownloadSuccessUI(randomId, actualFilePath, thumbnail) {
const progressEl = $(`${randomId}_prog`);
const openBtn = $(`${randomId}_openBtn`);
if (!progressEl) return;
let fullPath;
if (actualFilePath) {
const isAbsolute =
actualFilePath.startsWith("/") ||
/^[a-zA-Z]:[\\\/]/.test(actualFilePath);
fullPath = isAbsolute
? actualFilePath
: join(this.state.downloadDir, actualFilePath);
}
if (!fullPath) {
console.error("Could not resolve downloaded file path.");
return;
}
const expectedExt = fullPath.includes(".")
? fullPath.split(".").pop()
: "";
// If file doesn't exist at the expected path, attempt to find it with a loose matching strategy
if (
!existsSync(fullPath) &&
this.state.videoInfo &&
this.state.videoInfo.title
) {
try {
const originalTitle = this.state.videoInfo.title;
const dirFiles = require("fs").readdirSync(
this.state.downloadDir,
);
const looseTitle = originalTitle
.replace(/[^a-zA-Z0-9]/g, "")
.toLowerCase();
const matchedFile = dirFiles.find((f) => {
const cleanF = f.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
return (
cleanF.includes(looseTitle) &&
(expectedExt
? f
.toLowerCase()
.endsWith(expectedExt.toLowerCase())
: true)
);
});
if (matchedFile) {
fullPath = join(this.state.downloadDir, matchedFile);
}
} catch (err) {
console.error("Failed to execute loose filename recovery");
}
}
// Extract just the filename
const fullFilename = fullPath.split(/[\/\\]/).pop();
const baseFilename =
fullFilename.substring(0, fullFilename.lastIndexOf(".")) ||
fullFilename;
const ext = fullFilename.split(".").pop();
progressEl.innerHTML = ""; // Clear progress bar
openBtn.style.display = "flex";
openBtn.onclick = () => {
ipcRenderer.send("show-file", fullPath);
};
progressEl.style.display = "none";
$(`${randomId}_speed`).textContent = "";
// Send desktop notification
new Notification("ytDownloader", {
body: fullFilename,
icon: thumbnail,
}).onclick = () => {
ipcRenderer.send("show-file", fullPath);
};
// Add to download history
promises
.stat(fullPath)
.then((stat) => {
const fileSize = stat.size || 0;
ipcRenderer
.invoke("add-to-history", {
title: this.state.videoInfo.title,
url: this.state.videoInfo.url,
filename: baseFilename,
filePath: fullPath,
fileSize: fileSize,
format: ext,
thumbnail: thumbnail,
duration: this.state.videoInfo.duration,
})
.catch((err) =>
console.error("Error adding to history:", err),
);
})
.catch((error) => console.error("Error saving to history:", error));
}
/**
* Shows an error message in the main UI.
*/
_showError(errorMessage, url) {
$(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent =
i18n.__("errorNetworkOrUrl");
$(CONSTANTS.DOM_IDS.ERROR_BTN).style.display = "inline-block";
const errorDetails = $(CONSTANTS.DOM_IDS.ERROR_DETAILS);
errorDetails.innerHTML = `<strong>URL: ${url}</strong><br><br>${errorMessage}`;
errorDetails.title = i18n.__("clickToCopy");
}
/**
* Hides the info panel with an animation.
*/
_hideInfoPanel() {
const panel = $(CONSTANTS.DOM_IDS.HIDDEN_PANEL);
if (panel.style.display !== "none") {
panel.classList.remove("scaleUp");
panel.classList.add("scale");
setTimeout(() => {
panel.style.display = "none";
panel.classList.remove("scale");
}, 400);
}
}
/**
* Displays a temporary popup message.
*/
_showPopup(text, isError = false) {
let popupContainer = document.getElementById("popupContainer");
if (!popupContainer) {
popupContainer = document.createElement("div");
popupContainer.id = "popupContainer";
popupContainer.className = "popup-container";
document.body.appendChild(popupContainer);
}
const popup = document.createElement("span");
popup.textContent = text;
popup.classList.add("popup-item");
popup.style.background = isError ? "#ff6b6b" : "#54abde";
if (isError) {
popup.classList.add("popup-error");
}
popupContainer.appendChild(popup);
setTimeout(() => {
popup.style.opacity = "0";
setTimeout(() => {
popup.remove();
if (popupContainer.childElementCount === 0) {
popupContainer.remove();
}
}, 1000);
}, 2200);
}
/**
* Hides the main menu.
*/
_closeMenu() {
$(CONSTANTS.DOM_IDS.MENU_ICON).style.transform = "rotate(0deg)";
$(CONSTANTS.DOM_IDS.MENU).style.opacity = "0";
setTimeout(
() => ($(CONSTANTS.DOM_IDS.MENU).style.display = "none"),
500,
);
}
/**
* Cancels a download in progress or removes it from the queue.
* @param {string} id The ID of the download item.
*/
_cancelDownload(id) {
// If it's an active download
if (this.state.downloadControllers.has(id)) {
this.state.downloadControllers.get(id).abort();
}
// If it's in the queue
this.state.downloadQueue = this.state.downloadQueue.filter(
(job) => job.queueId !== id,
);
// If it has been downloaded, remove from the set
this.state.downloadedItems.delete(id);
this._fadeAndRemoveItem(id);
this._updateClearAllButton();
}
/**
* Fades and removes a DOM element.
*/
_fadeAndRemoveItem(id) {
const item = $(id);
if (item) {
item.classList.add("scale");
setTimeout(() => item.remove(), 500);
}
}
/**
* Removes all completed download items from the UI.
*/
_clearAllDownloaded() {
this.state.downloadedItems.forEach((id) => this._fadeAndRemoveItem(id));
this.state.downloadedItems.clear();
this._updateClearAllButton();
}
/**
* Shows or hides the "Clear All" button based on the number of completed items.
*/
_updateClearAllButton() {
const btn = $(CONSTANTS.DOM_IDS.CLEAR_BTN);
btn.style.display =
this.state.downloadedItems.size > 1 ? "inline-block" : "none";
}
/**
* Toggles between audio and video tabs
*/
_defaultVideoToggle() {
let defaultWindow = "video";
if (localStorage.getItem("defaultWindow")) {
defaultWindow = localStorage.getItem("defaultWindow");
}
if (defaultWindow == "video") {
selectVideo();
} else {
selectAudio();
}
}
/**
* @param {string} timeString
*/
parseTime(timeString) {
const parts = timeString.split(":").map((p) => parseInt(p.trim(), 10));
let totalSeconds = 0;
if (parts.length === 3) {
// H:MM:SS format
const [hrs, mins, secs] = parts;
if (
isNaN(hrs) ||
isNaN(mins) ||
isNaN(secs) ||
mins < 0 ||
mins > 59 ||
secs < 0 ||
secs > 59
)
return NaN;
totalSeconds = hrs * 3600 + mins * 60 + secs;
} else if (parts.length === 2) {
// MM:SS format
const [mins, secs] = parts;
if (isNaN(mins) || isNaN(secs) || secs < 0 || secs > 59) return NaN;
totalSeconds = mins * 60 + secs;
} else if (parts.length === 1) {
const [secs] = parts;
if (isNaN(secs)) return NaN;
totalSeconds = secs;
} else {
return NaN;
}
return totalSeconds;
}
_formatTime(duration) {
if (duration === null) {
return "";
}
const hrs = Math.floor(duration / 3600);
const mins = Math.floor((duration % 3600) / 60);
const secs = Math.floor(duration % 60);
const paddedMins = String(mins).padStart(2, "0");
const paddedSecs = String(secs).padStart(2, "0");
if (hrs > 0) {
// H:MM:SS format
return `${hrs}:${paddedMins}:${paddedSecs}`;
} else {
// MM:SS format
return `${paddedMins}:${paddedSecs}`;
}
}
/**
* @param {HTMLElement} movedSlider
*/
_updateSliderUI(movedSlider) {
const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER);
const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER);
const minTimeDisplay = $(CONSTANTS.DOM_IDS.START_TIME);
const maxTimeDisplay = $(CONSTANTS.DOM_IDS.END_TIME);
const rangeHighlight = $(CONSTANTS.DOM_IDS.SLIDER_RANGE_HIGHLIGHT);
let minValue = parseInt(minSlider.value);
let maxValue = parseInt(maxSlider.value);
const minSliderVal = parseInt(minSlider.min);
const maxSliderVal = parseInt(minSlider.max);
const sliderRange = maxSliderVal - minSliderVal;
// Prevent sliders from crossing each other
if (minValue >= maxValue) {
if (movedSlider && movedSlider.id === "min-slider") {
// Min must be at least 1 second less than Max
minValue = Math.max(minSliderVal, maxValue - 1);
minSlider.value = minValue;
} else {
// Max must be at least 1 second more than Min
maxValue = Math.min(maxSliderVal, minValue + 1);
maxSlider.value = maxValue;
}
}
minTimeDisplay.value = this._formatTime(minValue);
maxTimeDisplay.value = this._formatTime(maxValue);
const minPercent = ((minValue - minSliderVal) / sliderRange) * 100;
const maxPercent = ((maxValue - minSliderVal) / sliderRange) * 100;
rangeHighlight.style.left = `${minPercent}%`;
rangeHighlight.style.width = `${maxPercent - minPercent}%`;
}
/**
* @param {Event} e
*/
_handleTimeInputChange = (e) => {
const input = e.target;
let newSeconds = this.parseTime(input.value);
const minSlider = $("min-slider");
const maxSlider = $("max-slider");
if (isNaN(newSeconds)) {
input.value = this._formatTime(
input.id === "min-time" ? minSlider.value : maxSlider.value,
);
return;
}
const minSliderVal = parseInt(minSlider.min);
const maxSliderVal = parseInt(minSlider.max);
newSeconds = Math.max(minSliderVal, Math.min(maxSliderVal, newSeconds));
if (input.id === "min-time") {
if (newSeconds >= parseInt(maxSlider.value)) {
newSeconds = Math.max(
minSliderVal,
parseInt(maxSlider.value) - 1,
);
}
minSlider.value = newSeconds;
} else {
if (newSeconds <= parseInt(minSlider.value)) {
newSeconds = Math.min(
maxSliderVal,
parseInt(minSlider.value) + 1,
);
}
maxSlider.value = newSeconds;
}
this._updateSliderUI(null);
};
/**
* Sets the maximum duration for the video and updates the slider's max range.
* @param {number} duration - The total length of the video in seconds (must be an integer >= 1).
*/
setVideoLength(duration) {
const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER);
const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER);
if (typeof duration !== "number" || duration < 1) {
console.error(
"Invalid duration provided to setVideoLength. Must be a number greater than 0.",
);
minSlider.max = 0;
maxSlider.max = 0;
minSlider.value = 0;
maxSlider.value = 0;
return;
}
minSlider.max = duration;
maxSlider.max = duration;
const defaultMin = 0;
const defaultMax = duration;
minSlider.value = defaultMin;
maxSlider.value = defaultMax;
this._updateSliderUI(null);
}
/**
* Validates a URL and returns the sanitized version.
* @param {string} rawUrl - The URL to validate.
* @returns {string} - The sanitized URL.
* @throws {Error} - If the URL is invalid.
*/
validateUrl(rawUrl) {
const input = String(rawUrl ?? "").trim();
let parsed;
try {
parsed = new URL(input);
} catch {
throw new Error("invalidUrl");
}
return parsed.toString();
}
}
// --- Application Entry Point ---
document.addEventListener("DOMContentLoaded", () => {
const app = new YtDownloaderApp();
app.initialize();
});