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.
2308 lines
60 KiB
JavaScript
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();
|
|
});
|