mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-07-29 18:25:47 +02:00
- Add JSDoc comments across frontend and backend modules - Improve code readability and maintainability with consistent documentation style - Document Electron IPC handlers, UI utilities, and theme system - Add missing function/class documentation in theme loader and app services - Minor structural improvements and cleanup across Electron main process modules - Minor fixes on variable names
290 lines
9.9 KiB
JavaScript
290 lines
9.9 KiB
JavaScript
const { execFile } = require("child_process");
|
|
const { userYtDlp, defaultDownloadFolder } = require("../helpers/path.helpers");
|
|
const fs = require("fs");
|
|
const { logger } = require("../logger");
|
|
const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
|
|
const notify = require("../helpers/notify.helpers");
|
|
const path = require("path");
|
|
const { isSafePath } = require("../helpers/validation.helpers");
|
|
const { configFeatures } = require("../../config.js");
|
|
|
|
/**
|
|
* Reference to the currently running yt-dlp process.
|
|
*
|
|
* Used to support download cancellation and lifecycle
|
|
* management.
|
|
*
|
|
* @type {import("child_process").ChildProcess|null}
|
|
*/
|
|
let currentDownloadProcess = null;
|
|
|
|
/**
|
|
* Determines whether a URL targets a playlist.
|
|
*
|
|
* The detection is based on the presence of the YouTube
|
|
* playlist query parameter (`list`).
|
|
*
|
|
* @param {string} url - URL to inspect.
|
|
* @returns {boolean} True if the URL appears to be a playlist.
|
|
*/
|
|
function isPlaylistUrl(url) {
|
|
return url.includes("?list=") || url.includes("&list=");
|
|
}
|
|
|
|
/**
|
|
* Creates a dedicated download folder for a playlist.
|
|
*
|
|
* The playlist title is sanitized to ensure it can be safely
|
|
* used as a filesystem path. If the target folder already
|
|
* exists, an incremented suffix is appended until a unique
|
|
* folder name is found.
|
|
*
|
|
* If folder creation fails, a fallback folder named
|
|
* "Untitled Playlist X" is generated.
|
|
*
|
|
* @param {string} basePath - Parent download directory.
|
|
* @param {string} playlistTitle - Playlist title returned by yt-dlp.
|
|
* @returns {string} Absolute path of the created playlist folder.
|
|
* @throws {Error} If no valid folder can be created.
|
|
*/
|
|
function createPlaylistFolder(basePath, playlistTitle) {
|
|
const sanitizedTitle = playlistTitle
|
|
.replace(/[<>:"|?*]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.substring(0, 200);
|
|
|
|
const playlistPath = path.join(basePath, sanitizedTitle);
|
|
|
|
try {
|
|
if (!fs.existsSync(playlistPath)) {
|
|
fs.mkdirSync(playlistPath, { recursive: true });
|
|
logger.info(`Playlist folder created: ${playlistPath}`);
|
|
return playlistPath;
|
|
}
|
|
|
|
let counter = 1;
|
|
let newPath;
|
|
|
|
while (counter <= 1000) {
|
|
newPath = path.join(basePath, `${sanitizedTitle} ${counter}`);
|
|
if (!fs.existsSync(newPath)) {
|
|
fs.mkdirSync(newPath, { recursive: true });
|
|
logger.info(`Playlist folder created with increment: ${newPath}`);
|
|
return newPath;
|
|
}
|
|
counter++;
|
|
}
|
|
|
|
logger.error(`Could not find available playlist folder after 1000 attempts`);
|
|
throw new Error("Unable to create playlist folder");
|
|
|
|
} catch (err) {
|
|
logger.warn(`Failed to create playlist folder with title "${sanitizedTitle}": ${err.message}`);
|
|
|
|
// Fallback: create folder "Untitled Playlist X"
|
|
let counter = 1;
|
|
let fallbackPath;
|
|
|
|
while (counter <= 1000) {
|
|
fallbackPath = path.join(basePath, `Untitled Playlist ${counter}`);
|
|
if (!fs.existsSync(fallbackPath)) {
|
|
try {
|
|
fs.mkdirSync(fallbackPath, { recursive: true });
|
|
logger.info(`Fallback playlist folder created: ${fallbackPath}`);
|
|
return fallbackPath;
|
|
} catch (mkErr) {
|
|
counter++;
|
|
continue;
|
|
}
|
|
}
|
|
counter++;
|
|
}
|
|
|
|
logger.error(`Could not create playlist folder after 1000 attempts`);
|
|
throw new Error("Unable to create playlist folder");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts a yt-dlp download process and monitors its progress.
|
|
*
|
|
* Responsibilities:
|
|
* - Validates and prepares the output directory
|
|
* - Creates playlist folders when enabled
|
|
* - Builds yt-dlp command arguments
|
|
* - Spawns the yt-dlp process
|
|
* - Parses progress, speed and stage information
|
|
* - Notifies listeners about download events
|
|
* - Handles download completion and failures
|
|
*
|
|
* @param {Object} options - Download options.
|
|
* @param {string} options.url - Video or playlist URL.
|
|
* @param {string} [options.outputFolder] - Destination folder.
|
|
* @param {string} [options.playlistTitle] - Playlist title.
|
|
*
|
|
* @param {Function[]} listeners - Progress listeners.
|
|
* @param {Function[]} speedListeners - Download speed listeners.
|
|
* @param {Function[]} stageListeners - Processing stage listeners.
|
|
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
|
|
*
|
|
* @returns {Promise<string>}
|
|
* Resolves with the final output folder path once the
|
|
* download has completed successfully.
|
|
*/
|
|
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
|
|
|
|
let outputFolder = options.outputFolder || defaultDownloadFolder;
|
|
|
|
// Normalize path and validate it's safe (within Users folder)
|
|
let safeOutputFolder = path.resolve(outputFolder);
|
|
|
|
if (!isSafePath(safeOutputFolder)) {
|
|
logger.warn(`Path not allowed, using default instead: ${safeOutputFolder}`);
|
|
safeOutputFolder = path.resolve(defaultDownloadFolder);
|
|
}
|
|
|
|
// Create download folder if it doesn't exist
|
|
try {
|
|
fs.mkdirSync(safeOutputFolder, { recursive: true });
|
|
logger.info(`Output folder ready: ${safeOutputFolder}`);
|
|
} catch (err) {
|
|
logger.error(`Failed to create output folder: ${err.message}`);
|
|
return reject(new Error(`Unable to create download folder: ${err.message}`));
|
|
}
|
|
|
|
// Détecte si c'est une playlist et crée un dossier approprié
|
|
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
|
|
|
|
if (isPlaylist && configFeatures.createPlaylistFolders) {
|
|
try {
|
|
const playlistName = options.playlistTitle || "Untitled Playlist";
|
|
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
|
|
logger.info(`Playlist detected, using folder: ${safeOutputFolder}`);
|
|
} catch (err) {
|
|
logger.error(`Failed to create playlist folder: ${err.message}`);
|
|
return reject(err);
|
|
}
|
|
} else if (isPlaylist && !configFeatures.createPlaylistFolders) {
|
|
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
|
|
}
|
|
|
|
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder });
|
|
logger.info(`[yt-dlp args] ${args.join(" ")}`);
|
|
|
|
const child = execFile(userYtDlp, args);
|
|
currentDownloadProcess = child;
|
|
|
|
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
|
|
|
|
child.on("close", code => {
|
|
currentDownloadProcess = null;
|
|
listeners.forEach(fn => fn("done"));
|
|
if (code === 0) resolve(safeOutputFolder);
|
|
else if (code === null) reject(new Error(`Download cancelled by user`));
|
|
else reject(new Error(`YT-DLP failed with code : ${code}`));
|
|
});
|
|
|
|
|
|
child.stdout.on("data", data => {
|
|
data.toString().split("\n").forEach(line => {
|
|
if (!line.trim()) return;
|
|
logger.info(`[yt-dlp] ${line}`);
|
|
|
|
// Progress Bar
|
|
if (line.startsWith("[download] Destination:")) {
|
|
listeners.forEach(fn => fn("reset"));
|
|
stageListeners.forEach(fn => fn("Downloading..."));
|
|
}
|
|
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
|
|
if (match) listeners.forEach(fn => fn(parseFloat(match[1])));
|
|
|
|
if (line.includes("MiB/s") || line.includes("KiB/s")) {
|
|
const match = line.match(/\d+(\.\d+)?[KM]?iB\/s/);
|
|
if (match) {
|
|
const speed = match[0];
|
|
speedListeners.forEach(fn => fn(speed));
|
|
}
|
|
}
|
|
|
|
// Playlist item tracking
|
|
const itemMatch = line.match(/Downloading\s+(?:item|video)?\s*(\d+)\s+of\s+(\d+)/i);
|
|
if (itemMatch) {
|
|
const itemInfo = `Item ${itemMatch[1]} of ${itemMatch[2]}`;
|
|
playlistInfoListeners.forEach(fn => fn(itemInfo));
|
|
}
|
|
|
|
// Stage messages
|
|
if (line.includes("[Merger]")) {
|
|
stageListeners.forEach(fn => fn("Merging formats..."));
|
|
}
|
|
if (line.includes("[ExtractAudio]")) {
|
|
stageListeners.forEach(fn => fn("Extracting audio..."));
|
|
}
|
|
if (line.includes("[Metadata]")) {
|
|
stageListeners.forEach(fn => fn("Adding metadata..."));
|
|
}
|
|
if (line.includes("[ThumbnailsConvertor]")) {
|
|
stageListeners.forEach(fn => fn("Converting thumbnail..."));
|
|
}
|
|
if (line.includes("[EmbedThumbnail]")) {
|
|
stageListeners.forEach(fn => fn("Embedding thumbnail..."));
|
|
}
|
|
|
|
if (line.match(/ERROR: Could not copy .* cookie database/i)) {
|
|
notify.notifyCookiesBrowserError();
|
|
}
|
|
|
|
});
|
|
});
|
|
|
|
child.stderr.on("data", data => {
|
|
data.toString().split("\n").forEach(line => line.trim() && logger.error(`[yt-dlp stderr] ${line}`));
|
|
});
|
|
|
|
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancels the currently running yt-dlp process.
|
|
*
|
|
* Attempts to terminate the entire process tree to ensure
|
|
* that yt-dlp and any spawned FFmpeg processes are stopped.
|
|
*
|
|
* @returns {boolean}
|
|
* True if a download process was cancelled,
|
|
* otherwise false.
|
|
*/
|
|
function cancelDownload() {
|
|
if (currentDownloadProcess) {
|
|
logger.info("Cancelling download and killing all child processes...");
|
|
|
|
// Force kill the process and all its children with SIGKILL
|
|
try {
|
|
// Try to kill with SIGKILL on Windows (process group) or Unix
|
|
if (process.platform === 'win32') {
|
|
// On Windows, kill the process tree
|
|
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' });
|
|
} else {
|
|
// On Unix, send SIGKILL to process group
|
|
process.kill(-currentDownloadProcess.pid, 'SIGKILL');
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Error killing process: ${err.message}`);
|
|
// Fallback to regular kill
|
|
currentDownloadProcess.kill('SIGKILL');
|
|
}
|
|
|
|
currentDownloadProcess = null;
|
|
logger.info("Download cancelled successfully");
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
module.exports = { fetchDownload, cancelDownload };
|