mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-07-29 18:25:47 +02:00
refactor: add full documentation and fix theme loading robustness
- 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
This commit is contained in:
@@ -4,11 +4,47 @@ const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
|
||||
const { notifyDownloadFinished } = require("../helpers/notify.helpers");
|
||||
const { configFeatures } = require("../../config");
|
||||
|
||||
const listeners = [];
|
||||
const speedListeners = [];
|
||||
const stageListeners = [];
|
||||
const playlistInfoListeners = [];
|
||||
/**
|
||||
* Active Server-Sent Event subscribers receiving
|
||||
* download progress updates.
|
||||
*
|
||||
* Each listener receives a percentage value between
|
||||
* 0 and 100 during the download lifecycle.
|
||||
*/
|
||||
const progressListeners = [];
|
||||
|
||||
/**
|
||||
* Active Server-Sent Event subscribers receiving
|
||||
* download speed updates.
|
||||
*/
|
||||
const speedListeners = [];
|
||||
|
||||
/**
|
||||
* Active Server-Sent Event subscribers receiving
|
||||
* download stage updates.
|
||||
*/
|
||||
const stageListeners = [];
|
||||
|
||||
/**
|
||||
* Active Server-Sent Event subscribers receiving
|
||||
* playlist progress information.
|
||||
*/
|
||||
const playlistListeners = [];
|
||||
|
||||
/**
|
||||
* Handles download requests and starts a yt-dlp download.
|
||||
*
|
||||
* Validates user input, prepares download options and
|
||||
* delegates the download process to the download service.
|
||||
*
|
||||
* Once the download completes successfully, a desktop
|
||||
* notification may be displayed depending on the
|
||||
* application configuration.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function downloadController(req, res) {
|
||||
try {
|
||||
const options = {
|
||||
@@ -25,17 +61,30 @@ async function downloadController(req, res) {
|
||||
return res.status(400).send("Save Path Not Allowed.");
|
||||
}
|
||||
|
||||
// Get output folder when the download is finished
|
||||
const outputFolder = await fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners);
|
||||
const outputFolder = await fetchDownload(
|
||||
options,
|
||||
progressListeners,
|
||||
speedListeners,
|
||||
stageListeners,
|
||||
playlistListeners
|
||||
);
|
||||
|
||||
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
|
||||
res.send("Download Done !");
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Server Error in /download : ${err.message}`);
|
||||
res.status(500).send(`Server Error`);
|
||||
logger.error(`Server Error in /download: ${err?.message ?? err}`);
|
||||
res.status(500).send("Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Server-Sent Events (SSE) connection used to
|
||||
* stream download progress updates to the client.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
function progressController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -44,14 +93,21 @@ function progressController(req, res) {
|
||||
});
|
||||
|
||||
const sendProgress = percent => res.write(`data: ${percent}\n\n`);
|
||||
listeners.push(sendProgress);
|
||||
progressListeners.push(sendProgress);
|
||||
|
||||
req.on('close', () => {
|
||||
listeners.splice(listeners.indexOf(sendProgress), 1);
|
||||
progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Server-Sent Events (SSE) connection used to
|
||||
* stream download speed updates to the client.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
function speedController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -68,6 +124,14 @@ function speedController(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Server-Sent Events (SSE) connection used to
|
||||
* stream download stage updates such as downloading,
|
||||
* merging, extracting audio or embedding metadata.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
function stageController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -84,6 +148,14 @@ function stageController(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the currently running download process.
|
||||
*
|
||||
* Returns an error response if no download is active.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
function cancelDownloadController(req, res) {
|
||||
const cancelled = cancelDownload();
|
||||
if (cancelled) {
|
||||
@@ -95,6 +167,18 @@ function cancelDownloadController(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Server-Sent Events (SSE) connection used to
|
||||
* stream playlist progress information to the client.
|
||||
*
|
||||
* Example:
|
||||
* - Item 1 of 20
|
||||
* - Item 12 of 20
|
||||
* - Item 20 of 20
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
function playlistInfoController(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -103,10 +187,10 @@ function playlistInfoController(req, res) {
|
||||
});
|
||||
|
||||
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
|
||||
playlistInfoListeners.push(sendPlaylistInfo);
|
||||
playlistListeners.push(sendPlaylistInfo);
|
||||
|
||||
req.on('close', () => {
|
||||
playlistInfoListeners.splice(playlistInfoListeners.indexOf(sendPlaylistInfo), 1);
|
||||
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,70 +3,114 @@ const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
|
||||
const { logger } = require("../logger");
|
||||
const { isValidUrl } = require("../helpers/validation.helpers");
|
||||
|
||||
async function infoController(req, res){
|
||||
/**
|
||||
* Handles metadata retrieval for a video or playlist.
|
||||
*
|
||||
* This controller:
|
||||
* - Validates incoming URL (query or body)
|
||||
* - Calls yt-dlp metadata extraction service
|
||||
* - Detects playlist vs single video context
|
||||
* - Applies fallback strategy if playlist extraction fails
|
||||
* - Returns normalized parsed response (video or playlist)
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
async function infoController(req, res) {
|
||||
|
||||
const url = req.body.url || req.query.url; // POST et GET
|
||||
const url = req.body.url || req.query.url; // Supports POST and GET requests
|
||||
|
||||
|
||||
// If no url, non-string url or invalid url
|
||||
|
||||
if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("Invalid URL Or Missing");
|
||||
|
||||
logger.info(`/Info Request receive by the Info Controller. URL: ${url}`);
|
||||
|
||||
if (url.includes("&list") || url.includes("?list")) {
|
||||
logger.info("Estimated Data Type: Playlist")
|
||||
|
||||
} else{
|
||||
logger.info("Estimated Data Type: Video")
|
||||
/**
|
||||
* Basic validation:
|
||||
* - URL must exist
|
||||
* - Must be a string
|
||||
* - Must pass custom URL validation
|
||||
*/
|
||||
if (!url || typeof url !== "string" || !isValidUrl(url)) {
|
||||
return res.status(400).send("Invalid URL Or Missing");
|
||||
}
|
||||
|
||||
try {
|
||||
let data;
|
||||
let fetchError = null;
|
||||
logger.info(`Info request received. URL: ${url}`);
|
||||
|
||||
// Lightweight heuristic to detect playlist URLs
|
||||
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
|
||||
|
||||
logger.info(
|
||||
isPlaylistUrl
|
||||
? "Estimated Data Type: Playlist"
|
||||
: "Estimated Data Type: Video"
|
||||
);
|
||||
|
||||
try {
|
||||
data = await fetchInfo(url);
|
||||
} catch (err) {
|
||||
fetchError = err;
|
||||
|
||||
// If it's a playlist URL and fetch failed, try fetching just the single video
|
||||
if ((url.includes("&list") || url.includes("?list"))) {
|
||||
logger.info(`Playlist fetch failed: ${err.message}. Retrying with single video...`);
|
||||
|
||||
// Remove the &list parameter
|
||||
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
|
||||
|
||||
try {
|
||||
data = await fetchInfo(singleVideoUrl);
|
||||
logger.info(`Single video fetch succeeded after playlist failure`);
|
||||
} catch (retryErr) {
|
||||
logger.error(`Single video fetch also failed: ${retryErr.message}`);
|
||||
throw err; // Throw the original error
|
||||
let data;
|
||||
|
||||
/**
|
||||
* Primary metadata fetch attempt
|
||||
*/
|
||||
try {
|
||||
data = await fetchInfo(url);
|
||||
} catch (err) {
|
||||
|
||||
/**
|
||||
* Fallback strategy:
|
||||
* If playlist extraction fails, retry as single video
|
||||
*/
|
||||
if (isPlaylistUrl) {
|
||||
|
||||
logger.info(
|
||||
`Playlist fetch failed: ${err.message}. Retrying as single video...`
|
||||
);
|
||||
|
||||
// Remove playlist query parameter (best-effort cleanup)
|
||||
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
|
||||
|
||||
try {
|
||||
data = await fetchInfo(singleVideoUrl);
|
||||
logger.info("Single video fetch succeeded after playlist failure");
|
||||
} catch (retryErr) {
|
||||
logger.error(
|
||||
`Single video fetch also failed: ${retryErr.message}`
|
||||
);
|
||||
|
||||
// Preserve original error context
|
||||
throw err;
|
||||
}
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (data._type === "playlist") {
|
||||
/**
|
||||
* Normalize response based on yt-dlp output type
|
||||
*/
|
||||
if (data._type === "playlist") {
|
||||
|
||||
const playlist = parsePlaylist(data);
|
||||
logger.info(`Playlist : ${playlist.title} (${playlist.count} vidéos)`);
|
||||
return res.json(playlist);
|
||||
const playlist = parsePlaylist(data);
|
||||
|
||||
} else{
|
||||
const video = parseVideo(data);
|
||||
logger.info(`Unique Video: ${video.title}`);
|
||||
return res.json(video);
|
||||
}
|
||||
logger.info(
|
||||
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
|
||||
);
|
||||
|
||||
return res.json(playlist);
|
||||
|
||||
} else {
|
||||
|
||||
const video = parseVideo(data);
|
||||
|
||||
logger.info(`Video fetched: ${video.title}`);
|
||||
|
||||
return res.json(video);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Info controller error: ${err && err.message ? err.message : err}`);
|
||||
return res.status(500).send(`Unable to fetch info`);
|
||||
|
||||
logger.error(
|
||||
`Info controller error: ${err && err.message ? err.message : err}`
|
||||
);
|
||||
|
||||
return res.status(500).send("Unable to fetch info");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {infoController};
|
||||
module.exports = { infoController };
|
||||
@@ -5,6 +5,26 @@ const { ffmpegPath, denoPath} = require("./path.helpers.js");
|
||||
const { configFeatures } = require("../../config.js");
|
||||
const { logger } = require("../logger.js");
|
||||
|
||||
/**
|
||||
* Validates whether the provided codec is supported by the application.
|
||||
*
|
||||
* If the codec is supported, it is returned unchanged.
|
||||
* Otherwise, a warning is logged and the default codec ("h264")
|
||||
* is returned as a fallback.
|
||||
*
|
||||
* Supported codecs:
|
||||
* - av01
|
||||
* - vp9.2
|
||||
* - vp9
|
||||
* - h265
|
||||
* - h264
|
||||
* - vp8
|
||||
* - h263
|
||||
* - theora
|
||||
*
|
||||
* @param {string} codec - Codec identifier to validate.
|
||||
* @returns {string} A valid codec identifier.
|
||||
*/
|
||||
function validateCodec(codec){
|
||||
|
||||
const validCodec = [
|
||||
@@ -20,6 +40,28 @@ function validateCodec(codec){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete yt-dlp argument list based on the
|
||||
* application configuration and download options.
|
||||
*
|
||||
* The generated arguments include:
|
||||
* - Browser cookie extraction
|
||||
* - FFmpeg integration
|
||||
* - Deno runtime configuration
|
||||
* - Metadata and thumbnail embedding
|
||||
* - Playlist handling
|
||||
* - Video/audio format selection
|
||||
* - Quality filtering
|
||||
* - Output file destination
|
||||
*
|
||||
* @param {Object} options - Download configuration.
|
||||
* @param {string} options.url - Video or playlist URL.
|
||||
* @param {boolean} options.audioOnly - Whether to extract audio only.
|
||||
* @param {string} options.quality - Requested quality preset.
|
||||
* @param {string} options.outputFolder - Download destination folder.
|
||||
*
|
||||
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
|
||||
*/
|
||||
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
|
||||
|
||||
logger.info("--- CONFIGURATION ---");
|
||||
|
||||
@@ -4,10 +4,33 @@ const path = require("path");
|
||||
const notify = require("./notify.helpers")
|
||||
const { logger } = require("../logger");
|
||||
|
||||
/**
|
||||
* Detects the first supported browser installed on the system.
|
||||
*
|
||||
* The detected browser is used by yt-dlp to import authentication
|
||||
* cookies when required by certain platforms.
|
||||
*
|
||||
* Currently, Firefox is the only browser officially supported
|
||||
* by the application.
|
||||
*
|
||||
* If no supported browser is found:
|
||||
* - A warning is logged
|
||||
* - A notification is displayed to the user
|
||||
* - "firefox" is returned as a fallback to allow yt-dlp to
|
||||
* handle the error gracefully without crashing the application
|
||||
*
|
||||
* @returns {string} The detected browser identifier.
|
||||
*/
|
||||
function getUserBrowser() {
|
||||
const userProfile = os.homedir();
|
||||
|
||||
// List of browsers supported by yt-dlp (Currently I can only guarantee Firefox, Sorry)
|
||||
/**
|
||||
* Browsers supported by yt-dlp cookie extraction.
|
||||
*
|
||||
* Only Firefox is currently enabled and tested.
|
||||
* Additional browsers can be enabled in the future
|
||||
* once compatibility has been verified.
|
||||
*/
|
||||
const browsers = [
|
||||
{ name: "firefox", path: path.join(userProfile, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") },
|
||||
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
|
||||
@@ -19,7 +42,7 @@ function getUserBrowser() {
|
||||
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
|
||||
];
|
||||
|
||||
// Search for an available browser
|
||||
// Search for the first available browser profile.
|
||||
for (const browser of browsers) {
|
||||
if (fs.existsSync(browser.path)) {
|
||||
logger.info(`Browser found: ${browser.name}`);
|
||||
@@ -27,12 +50,12 @@ function getUserBrowser() {
|
||||
}
|
||||
}
|
||||
|
||||
// No browser found - notify user
|
||||
// No supported browser found => Notify User
|
||||
logger.warn("No supported browser found on the system");
|
||||
notify.notifyFirefoxBrowserMissing();
|
||||
|
||||
// Fallback: return "Firefox" to let YT-DLP manage error
|
||||
// this avoid app crash
|
||||
// Fallback to Firefox and let yt-dlp handle the error gracefully.
|
||||
// This prevents the application from crashing
|
||||
return "firefox";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
const { Notification, shell } = require("electron");
|
||||
const { iconPaths } = require("./path.helpers");
|
||||
const { logger } = require("../logger");
|
||||
|
||||
/**
|
||||
* Displays a system notification when a download completes successfully.
|
||||
*
|
||||
* If enabled, clicking the notification opens the download folder
|
||||
* using the system file explorer.
|
||||
*
|
||||
* @param {string} folder - Path to the completed download folder.
|
||||
* @param {boolean} notifyEnabled - Whether notifications are enabled in config.
|
||||
*/
|
||||
function notifyDownloadFinished(folder, notifyEnabled = true) {
|
||||
if (!notifyEnabled) return;
|
||||
if (!folder) return;
|
||||
|
||||
const notif = new Notification({
|
||||
title: "Freedom Loader",
|
||||
@@ -14,6 +25,15 @@ function notifyDownloadFinished(folder, notifyEnabled = true) {
|
||||
notif.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a notification when browser cookies cannot be extracted.
|
||||
*
|
||||
* This usually indicates:
|
||||
* - User is not logged in to the browser
|
||||
* - Browser profile is inaccessible
|
||||
*
|
||||
* Clicking the notification opens a tutorial link.
|
||||
*/
|
||||
function notifyCookiesBrowserError(){
|
||||
const notif = new Notification({
|
||||
title: "Cookies Error",
|
||||
@@ -21,23 +41,41 @@ function notifyCookiesBrowserError(){
|
||||
icon: iconPaths.error,
|
||||
});
|
||||
|
||||
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"));
|
||||
notif.on("click", () =>
|
||||
shell.openExternal(
|
||||
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||
)
|
||||
);
|
||||
|
||||
notif.show();
|
||||
}
|
||||
|
||||
function notifyFirefoxBrowserMissing(){
|
||||
/**
|
||||
* Displays a notification when Firefox is not detected
|
||||
* on the user system.
|
||||
*
|
||||
* This is required for cookie extraction via yt-dlp.
|
||||
*
|
||||
* Clicking the notification opens an installation guide.
|
||||
*/
|
||||
function notifyFirefoxBrowserMissing() {
|
||||
const notif = new Notification({
|
||||
title: "Firefox Missing",
|
||||
body: "Firefox was not found on your system. Click here to follow the installation guide",
|
||||
icon: iconPaths.error,
|
||||
});
|
||||
|
||||
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"))
|
||||
notif.on("click", () =>
|
||||
shell.openExternal(
|
||||
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||
)
|
||||
);
|
||||
|
||||
notif.show();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notifyDownloadFinished,
|
||||
notifyDownloadFinished,
|
||||
notifyCookiesBrowserError,
|
||||
notifyFirefoxBrowserMissing
|
||||
notifyFirefoxBrowserMissing,
|
||||
};
|
||||
@@ -1,27 +1,38 @@
|
||||
/**
|
||||
* Normalizes a video payload by attaching the resource type.
|
||||
*
|
||||
* @param {Object} data - Raw video metadata.
|
||||
* @returns {Object} Normalized video object.
|
||||
*/
|
||||
function parseVideo(data) {
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
...data
|
||||
};
|
||||
return {
|
||||
type: "video",
|
||||
...data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a raw playlist payload into a normalized structure
|
||||
* containing playlist metadata and a simplified list of videos.
|
||||
*
|
||||
* @param {Object} data - Raw playlist metadata.
|
||||
* @returns {Object} Normalized playlist object.
|
||||
*/
|
||||
function parsePlaylist(data) {
|
||||
|
||||
return {
|
||||
type: "playlist",
|
||||
title: data.title || data.id,
|
||||
channel: data.uploader,
|
||||
count: data.entries.length,
|
||||
videos: (data.entries || []).map(v => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
url: v.webpage_url,
|
||||
duration: v.duration,
|
||||
thumbnail: v.thumbnail,
|
||||
uploader: v.uploader
|
||||
}))
|
||||
};
|
||||
return {
|
||||
type: "playlist",
|
||||
title: data.title || data.id,
|
||||
channel: data.uploader,
|
||||
count: data.entries.length,
|
||||
videos: (data.entries || []).map(v => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
url: v.webpage_url,
|
||||
duration: v.duration,
|
||||
thumbnail: v.thumbnail,
|
||||
uploader: v.uploader
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseVideo, parsePlaylist };
|
||||
module.exports = { parseVideo, parsePlaylist };
|
||||
@@ -14,13 +14,23 @@ const resourcesPath = config.devMode
|
||||
// Default download folder (centralized)
|
||||
const defaultDownloadFolder = path.join(os.homedir(), "Downloads", "Freedom Loader");
|
||||
|
||||
// Binary paths
|
||||
/**
|
||||
* Runtime-resolved binary paths.
|
||||
* These values differ between development and production builds.
|
||||
*/
|
||||
|
||||
let userYtDlp;
|
||||
let ffmpegPath;
|
||||
let denoPath;
|
||||
|
||||
/** Source binary bundled inside application resources (used for first-time copy) */
|
||||
const sourceYtDlp = path.join(resourcesPath, "binaries","yt-dlp.exe");
|
||||
|
||||
/**
|
||||
* Resolve binary locations depending on environment:
|
||||
* - dev: local resources folder
|
||||
* - prod: extracted userData + packaged resources
|
||||
*/
|
||||
if (config.devMode) {
|
||||
userYtDlp = path.join(__dirname, "../../ressources/yt-dlp.exe");
|
||||
ffmpegPath = path.join(__dirname, "../../ressources/"); // <- has ffmpeg.exe and ffprobe.exe
|
||||
@@ -35,14 +45,20 @@ if (config.devMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Notification icon paths
|
||||
/**
|
||||
* Centralized notification and UI icon paths.
|
||||
* Used by Electron notifications and UI components.
|
||||
*/
|
||||
const iconPaths = {
|
||||
confirm: path.join(resourcesPath, "confirm-icon.png"),
|
||||
error: path.join(resourcesPath, "error.png"),
|
||||
app: path.join(resourcesPath, "app-icon.ico")
|
||||
};
|
||||
|
||||
// Binary paths for verification
|
||||
/**
|
||||
* Static reference paths to bundled binaries.
|
||||
* Used mainly for verification and diagnostics.
|
||||
*/
|
||||
const binaryPaths = {
|
||||
ytDlp: path.join(resourcesPath, "binaries", "yt-dlp.exe"),
|
||||
ffmpeg: path.join(resourcesPath, "binaries", "ffmpeg.exe"),
|
||||
@@ -50,10 +66,21 @@ const binaryPaths = {
|
||||
deno: path.join(resourcesPath, "binaries", "deno.exe")
|
||||
};
|
||||
|
||||
// Theme paths - themes stored in AppData to survive updates
|
||||
/**
|
||||
* Theme storage system:
|
||||
* - default themes come from app resources
|
||||
* - user themes are persisted in userData to survive updates
|
||||
*/
|
||||
const userThemesPath = path.join(app.getPath("userData"), "themes");
|
||||
const defaultThemesSourcePath = path.join(resourcesPath, "theme");
|
||||
|
||||
/**
|
||||
* Initializes user theme directory and ensures default themes exist.
|
||||
*
|
||||
* - Creates user themes folder if missing
|
||||
* - Copies bundled themes from resources if not present
|
||||
* - Applies permissive permissions for runtime modification
|
||||
*/
|
||||
function initUserThemes() {
|
||||
try {
|
||||
if (!fs.existsSync(userThemesPath)) {
|
||||
@@ -94,6 +121,13 @@ function initUserThemes() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory and its contents.
|
||||
*
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* @throws Error If copy operation fails
|
||||
*/
|
||||
function copyDirectory(src, dest) {
|
||||
try {
|
||||
if (!fs.existsSync(dest)) {
|
||||
@@ -116,8 +150,9 @@ function copyDirectory(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!userYtDlp){ logger.error("Missing YT-DLP")}
|
||||
if (!ffmpegPath){ logger.error("Missing FFMPEG")}
|
||||
if (!denoPath){ logger.error("Missing DENO")}
|
||||
// Runtime validation of critical binaries
|
||||
if (!userYtDlp){ logger.error("Missing yt-dlp binary")}
|
||||
if (!ffmpegPath){ logger.error("Missing ffmpeg binary")}
|
||||
if (!denoPath){ logger.error("Missing deno binary")}
|
||||
|
||||
module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes };
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
const RateLimit = require("express-rate-limit");
|
||||
|
||||
|
||||
/* Rate Limite */
|
||||
const rateLimite = RateLimit({
|
||||
/**
|
||||
* Rate limiter middleware used to protect public endpoints
|
||||
* against abusive or excessive requests.
|
||||
*
|
||||
* Restrictions:
|
||||
* - 5 requests per IP address
|
||||
* - 15-minute sliding window
|
||||
*
|
||||
* When the limit is exceeded, a HTTP 429 (Too Many Requests)
|
||||
* response is returned.
|
||||
*/
|
||||
const rateLimit = RateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // limit each IP to 100 requests per windowMs on the root path
|
||||
max: 5,
|
||||
message: "Too many requests, please try again later.",
|
||||
statusCode: 429, // HTTP status code for "Too Many Requests"
|
||||
statusCode: 429,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
rateLimite
|
||||
rateLimit
|
||||
}
|
||||
@@ -1,21 +1,43 @@
|
||||
const path = require("path");
|
||||
|
||||
function isValidUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Validates whether the provided string is a well-formed URL.
|
||||
*
|
||||
* The validation relies on the native URL constructor and
|
||||
* returns `true` only if the value can be successfully parsed.
|
||||
*
|
||||
* @param {string} url - URL to validate.
|
||||
* @returns {boolean} True if the URL is valid, otherwise false.
|
||||
*/
|
||||
function isValidUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether a folder path is considered safe for
|
||||
* file operations.
|
||||
*
|
||||
* Security checks:
|
||||
* - Rejects empty or very short paths.
|
||||
* - Resolves the absolute path before validation.
|
||||
* - Prevents access to sensitive Windows system directories.
|
||||
*
|
||||
* @param {string} folder - Folder path to validate.
|
||||
* @returns {boolean} True if the path is considered safe, otherwise false.
|
||||
*/
|
||||
function isSafePath(folder) {
|
||||
if (!folder || folder.length < 3) return false;
|
||||
|
||||
const unsafe = ["System32", "\\Windows"];
|
||||
const resolved = path.resolve(folder);
|
||||
|
||||
return !unsafe.some(u => resolved.includes(u));
|
||||
}
|
||||
|
||||
module.exports = { isValidUrl, isSafePath };
|
||||
module.exports = { isValidUrl, isSafePath };
|
||||
@@ -20,7 +20,15 @@ const logFormat = format.combine(
|
||||
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
|
||||
);
|
||||
|
||||
// Logger configuration
|
||||
/**
|
||||
* Logger Instance with differents types
|
||||
* - info
|
||||
* - error
|
||||
* - warn
|
||||
*
|
||||
* You need to specify the text that need to be displayed
|
||||
* @param string
|
||||
*/
|
||||
const logger = createLogger({
|
||||
level: "info",
|
||||
format: logFormat,
|
||||
@@ -40,11 +48,17 @@ const logger = createLogger({
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Start Log Session
|
||||
*/
|
||||
function logSessionStart() {
|
||||
logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
|
||||
logger.info(`Application Version: ${config.version}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Log Session
|
||||
*/
|
||||
function logSessionEnd() {
|
||||
logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require("express");
|
||||
const path = require("path");
|
||||
const { logger, logSessionEnd } = require("./logger");
|
||||
const config = require("../config");
|
||||
const { rateLimite } = require("./helpers/rateLimit.helpers");
|
||||
const { rateLimit } = require("./helpers/rateLimit.helpers");
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -14,10 +14,18 @@ app.use(express.static(path.join(__dirname, "../public")));
|
||||
// Routes
|
||||
app.use("/download", require("./routes/download.route"));
|
||||
app.use("/info", require("./routes/info.route"));
|
||||
app.get("/", rateLimite, (req, res) => {
|
||||
app.get("/", rateLimit, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../public/index.html"));
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes and starts the Express HTTP server.
|
||||
*
|
||||
* - Binds the server to `config.applicationPort`
|
||||
* - Resolves with the HTTP server instance on successful startup
|
||||
* - Rejects on server binding or runtime errors
|
||||
* - Registers SIGINT/SIGTERM handlers for graceful shutdown
|
||||
*/
|
||||
async function startServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(config.applicationPort, () => {
|
||||
@@ -30,6 +38,12 @@ async function startServer() {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
/**
|
||||
* Clean exit function
|
||||
* - Stop Log
|
||||
* - Stop Express Server
|
||||
* - Stop Electron App
|
||||
*/
|
||||
const gracefulExit = () => {
|
||||
logSessionEnd();
|
||||
server.close(() => {
|
||||
|
||||
@@ -8,13 +8,45 @@ 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;
|
||||
|
||||
// Détecte si une URL est une playlist
|
||||
/**
|
||||
* 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, '')
|
||||
@@ -74,6 +106,32 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
@@ -191,6 +249,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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...");
|
||||
|
||||
@@ -3,8 +3,30 @@ const { userYtDlp, denoPath } = require("../helpers/path.helpers");
|
||||
const getUserBrowser = require("../helpers/getBrowser.helpers");
|
||||
const { logger } = require("../logger");
|
||||
|
||||
/**
|
||||
* Retrieves metadata for a video or playlist using yt-dlp.
|
||||
*
|
||||
* The command executes yt-dlp in JSON mode and returns the
|
||||
* parsed response object without downloading any media.
|
||||
*
|
||||
* Features:
|
||||
* - Supports videos and playlists
|
||||
* - Imports browser cookies when available
|
||||
* - Uses Deno for JavaScript extraction
|
||||
* - Applies a 30-second timeout
|
||||
* - Limits response size to 10 MB
|
||||
*
|
||||
* @param {string} url - Video or playlist URL to inspect.
|
||||
* @returns {Promise<Object>} Resolves with the metadata returned by yt-dlp.
|
||||
*
|
||||
*/
|
||||
function fetchInfo(url) {
|
||||
const args = [
|
||||
|
||||
/**
|
||||
* Base yt-dlp arguments used to retrieve metadata
|
||||
* without downloading media files.
|
||||
*/
|
||||
const metadataArgs = [
|
||||
"--js-runtimes", `deno:${denoPath}`,
|
||||
"--dump-single-json",
|
||||
"--ignore-errors",
|
||||
@@ -16,9 +38,9 @@ function fetchInfo(url) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
execFile(userYtDlp,[...args, url],
|
||||
execFile(userYtDlp,[...metadataArgs , url],
|
||||
{ timeout: 30_000, // 30s, if more timeout
|
||||
maxBuffer: 10 * 1024 * 1024 }, // 10MO max response (Default: 200ko)
|
||||
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||
|
||||
(error, stdout, stderr) => {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user