28 Commits

Author SHA1 Message Date
MasterAcnolo
787cc69600 fix!: major security issues. No host were filled and anyone through LAN could make undesirable things 2026-11-12 19:33:42 +01:00
MasterAcnolo
f650770df7 test: add unit tests for configuration and download controllers and handle electron app initialization and improve devMode logic 2026-10-28 21:58:46 +01:00
MasterAcnolo
f0f3018f80 docs: config autoStart missing values filling notes 2026-10-28 21:58:11 +01:00
MasterAcnolo
061c6b3b66 refactor(download): controller with:
- Event emitters
- Improved error handling.
- Can not force a download from qualities different from current set
2026-10-28 21:57:46 +01:00
MasterAcnolo
c9bb529b47 refactor(info): responsabilities is now better managed between controller and services.
Error is handled better. Normalize info
2026-10-28 21:56:11 +01:00
MasterAcnolo
8c3e1135e5 fix: linting 2026-10-28 21:52:26 +01:00
MasterAcnolo
fa5776b254 fix: linting 2026-10-28 21:52:22 +01:00
MasterAcnolo
ef68484828 fix: app management in path.helpers.js, used for unit test 2026-10-28 21:49:37 +01:00
MasterAcnolo
fdfe30e796 feat(download): update fetch requests to use JSON format and improve error handling 2026-10-28 21:48:47 +01:00
MasterAcnolo
0699656394 fix: autoUpdateYtDlp settings missing in IPC_Handlers 2026-10-28 21:47:42 +01:00
MasterAcnolo
fbe30f7ee2 feat: implement rate limits for downloads and info requests 2026-10-28 21:43:31 +01:00
MasterAcnolo
0da6df03cb feat(path): enhance path resolution for resources and user themes based on app context 2026-10-28 21:42:52 +01:00
MasterAcnolo
650696b9f5 feat(config): add default configuration path resolver and merge missing feature flags 2026-10-28 21:40:13 +01:00
MasterAcnolo
634108784e refactor(download): to cancel the download, we now retrieve the PID and execute the kill command depends on the OS. 2026-10-28 21:38:27 +01:00
MasterAcnolo
1b83272aaf feat: add auto-update option for yt-dlp in settings 2026-10-28 17:46:43 +01:00
MasterAcnolo
dc898e1235 feat: add UI toast notification for missing Firefox browser 2026-10-27 20:19:39 +01:00
MasterAcnolo
7c5ccb1527 build: optimize bundle size (only gain 1~2 MO, but it's still a weight reduction) 2026-10-26 20:43:00 +01:00
MasterAcnolo
f5259f7ade fix: server instance silent crash on close 2026-10-25 19:52:20 +01:00
MasterAcnolo
1fcf7cd988 docs: bump package.json app version to 1.6.5 2026-10-25 19:49:15 +01:00
MasterAcnolo
2e343cbd0c fix: .deb package format name. And remove "artifact" name from all configuration 2026-10-23 22:39:33 +02:00
Axel Nicolas
69de9eb9fc Merge pull request #58 from MasterAcnolo/1.6.4
1.6.4 Version
2026-10-07 20:35:34 +02:00
MasterAcnolo
cfa85649b3 docs: update changelog to reflect recent changes and improvements 2026-10-07 19:25:51 +02:00
MasterAcnolo
773ed9550d refactor: remove autoUpdate feature from configuration and UI 2026-10-07 19:24:47 +02:00
MasterAcnolo
ea3a475525 docs: add option to auto-display changelog after updates in configuration 2026-10-07 18:19:58 +02:00
MasterAcnolo
738d5ec62f fix: update README to clarify custom top bar feature and add cache directory information 2026-10-07 18:04:58 +02:00
MasterAcnolo
aff7637358 fix: remove experimental label from system tray option description 2026-10-07 17:22:44 +02:00
MasterAcnolo
7ee6d6fbe7 refactor: remove custom top bar feature from configuration and UI 2026-10-07 17:21:10 +02:00
MasterAcnolo
6aa3a39819 feat: options to not show changelog on app init 2026-10-07 10:25:29 +02:00
37 changed files with 1325 additions and 332 deletions

View File

@@ -56,7 +56,7 @@ The primary goal is to make media downloading accessible to users who want offli
### User Interface ### User Interface
- **Custom window controls** - Frameless window with custom top bar (optional) - **Custom window controls** - Frameless window with custom top bar
- **Custom themes** - ZIP-based theme system with live preview - **Custom themes** - ZIP-based theme system with live preview
- **Theme Workshop** - web-based theme creator and browser - **Theme Workshop** - web-based theme creator and browser
- **Toast notifications** - in-app feedback system - **Toast notifications** - in-app feedback system
@@ -151,6 +151,7 @@ freedom-loader
|---------------|---------------------------------------------------------|--------------------------------------| |---------------|---------------------------------------------------------|--------------------------------------|
| **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` | | **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
| **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` | | **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
| **Cache** | ``
> [!NOTE] > [!NOTE]
> The download folder can be changed anytime via the "Edit" button in the UI, on both platforms. > The download folder can be changed anytime via the "Edit" button in the UI, on both platforms.
@@ -209,6 +210,9 @@ If you encounter issues:
Freedom Loader can be configured either through the settings panel in the UI or by directly editing the `config.json` file located in the application data directory. Freedom Loader can be configured either through the settings panel in the UI or by directly editing the `config.json` file located in the application data directory.
When the application starts, any settings missing from an existing `config.json` are automatically added using the
defaults from `config/config.default.json`. Existing values and unknown custom keys are preserved.
### Configuration File Location ### Configuration File Location
#### Windows #### Windows
@@ -224,14 +228,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
### Available Options ### Available Options
```json ```json
{ {
"autoUpdate": true,
"systemTray": true, "systemTray": true,
"discordRPC": true, "discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true, "autoCheckInfo": true,
"addThumbnail": true, "addThumbnail": true,
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoUpdateYtDlp": true,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false, "keepPlaylistOrder": false,
"createPlaylistFolders": true, "createPlaylistFolders": true,
@@ -241,6 +244,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true, "notifySystem": true,
"enableHardwareAcceleration": true, "enableHardwareAcceleration": true,
"showChanglogsAfterUpdate": true,
"theme": "dark" "theme": "dark"
} }
``` ```
@@ -249,14 +253,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
| Option | Type | Default | Description | | Option | Type | Default | Description |
|------------------------------|---------|----------|------------------------------------------------------------| |------------------------------|---------|----------|------------------------------------------------------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates | | `systemTray` | boolean | `false` | Allow app to minimize on close |
| `systemTray` | boolean | `false` | Allow app to minimize on close [EXPERIMENTAL] |
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration | | `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
| `customTopBar` | boolean | `true` | Use custom application top bar |
| `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste | | `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files | | `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files | | `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging | | `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoUpdateYtDlp` | boolean | `true` | Automatically update yt-dlp when the app starts |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists | | `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album | | `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
| `createPlaylistFolders` | boolean | `false` | Automatically create a folder for a playlist | | `createPlaylistFolders` | boolean | `false` | Automatically create a folder for a playlist |
@@ -266,6 +269,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
| `downloadSystem` | boolean | `true` | Enable download system | | `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications | | `notifySystem` | boolean | `true` | Enable system notifications |
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration | | `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
| `showChanglogsAfterUpdate` | boolean | `true` | Auto-display changelog popup after and update |
| `theme` | string | `dark` | Current Theme Name | | `theme` | string | `dark` | Current Theme Name |
### Supported Codecs ### Supported Codecs

6
app/ipcHandlers.js Normal file → Executable file
View File

@@ -30,21 +30,21 @@ const { getChangelog, markChangelogSeen } = require("./changelogManager");
* Acts as a basic validation layer for IPC "set-feature". * Acts as a basic validation layer for IPC "set-feature".
*/ */
const FEATURE_WHITELIST = new Set([ const FEATURE_WHITELIST = new Set([
"autoUpdate",
"systemTray", "systemTray",
"discordRPC", "discordRPC",
"customTopBar",
"autoCheckInfo", "autoCheckInfo",
"addThumbnail", "addThumbnail",
"addMetadata", "addMetadata",
"verboseLogs", "verboseLogs",
"autoUpdateYtDlp",
"autoDownloadPlaylist", "autoDownloadPlaylist",
"keepPlaylistOrder", "keepPlaylistOrder",
"customCodec", "customCodec",
"theme", "theme",
"createPlaylistFolders", "createPlaylistFolders",
"notifySystem", "notifySystem",
"enableHardwareAcceleration" "enableHardwareAcceleration",
"showChanglogsAfterUpdate"
]); ]);
/** /**

22
app/toastNotifier.js Normal file
View File

@@ -0,0 +1,22 @@
const {getMainWindow} = require("./windowManager");
/**
* Sends a toast notification to the renderer UI when the main window is ready.
*
* @param {string} message
* @param {"success"|"error"|"warning"|"info"} [type="info"]
* @param {number} [duration=4000]
* @returns {boolean} Whether the toast was sent.
*/
function sendToast(message, type = "info", duration = 4000) {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
return false;
}
mainWindow.webContents.send("show-toast", {message, type, duration});
return true;
}
module.exports = {sendToast};

View File

@@ -57,7 +57,7 @@ async function createMainWindow() {
height: 800, height: 800,
minWidth: 750, minWidth: 750,
minHeight: 800, minHeight: 800,
frame: !configFeatures.customTopBar, frame: false, // Force custom topbar for consistent UI across platforms, and less maintenance headaches with native titlebar actions
devTools: !app.isPackaged, devTools: !app.isPackaged,
show: false, show: false,
webPreferences: { webPreferences: {

View File

@@ -1,5 +1,10 @@
const packageJson = require("./package.json"); const packageJson = require("./package.json");
const { app } = require("electron"); let app;
try {
app = require("electron").app;
} catch (e) {
app = null;
}
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -7,7 +12,7 @@ const path = require("path");
* Indicates whether the application is running in development mode. * Indicates whether the application is running in development mode.
* Determined by Electron's packaging state. * Determined by Electron's packaging state.
*/ */
const devMode = !app.isPackaged || process.env.NODE_ENV === "test"; const devMode = app && !app.isPackaged ? true : app ? !app.isPackaged || process.env.NODE_ENV === "test" : process.env.NODE_ENV !== "production";
/** /**
* Resolves the configuration file path depending on runtime environment. * Resolves the configuration file path depending on runtime environment.
@@ -57,6 +62,40 @@ function resolveConfigPath() {
*/ */
const featuresPath = resolveConfigPath(); const featuresPath = resolveConfigPath();
/**
* Resolves the bundled default configuration path.
*
* @returns {string} Absolute path to the default configuration file
*/
function getDefaultConfigPath() {
return devMode
? path.join(__dirname, "config", "config.default.json")
: path.join(process.resourcesPath, "config", "config.default.json");
}
/**
* Adds missing default feature flags without overwriting user settings.
*
* Unknown user-defined keys are intentionally preserved.
*
* @param {Object} defaults - Default feature flags
* @param {Object} current - User feature flags
* @returns {{features: Object, changed: boolean}} Merged features and migration state
*/
function mergeMissingFeatures(defaults, current) {
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
throw new Error("Default configuration must be a JSON object");
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
throw new Error("User configuration must be a JSON object");
}
const features = {...defaults, ...current};
const changed = Object.keys(defaults).some((key) => !Object.prototype.hasOwnProperty.call(current, key));
return {features, changed};
}
/** /**
* Loads and parses the feature configuration file. * Loads and parses the feature configuration file.
* *
@@ -64,8 +103,15 @@ const featuresPath = resolveConfigPath();
* @throws {Error} If the configuration file is missing or invalid * @throws {Error} If the configuration file is missing or invalid
*/ */
function loadFeatures() { function loadFeatures() {
const raw = fs.readFileSync(featuresPath, "utf-8"); const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
return JSON.parse(raw); const current = JSON.parse(fs.readFileSync(featuresPath, "utf-8"));
const {features, changed} = mergeMissingFeatures(defaults, current);
if (changed) {
fs.writeFileSync(featuresPath, JSON.stringify(features, null, 2), "utf-8");
}
return features;
} }
/** /**
@@ -77,8 +123,7 @@ function loadFeatures() {
*/ */
function reloadFeatures() { function reloadFeatures() {
try { try {
const raw = fs.readFileSync(featuresPath, "utf-8"); return loadFeatures();
return JSON.parse(raw);
} catch (err) { } catch (err) {
// Fallback to cached snapshot if file read fails // Fallback to cached snapshot if file read fails
return configFeatures; return configFeatures;
@@ -128,4 +173,9 @@ module.exports = {
* Path to the active configuration file * Path to the active configuration file
*/ */
featuresPath, featuresPath,
/**
* Merges missing default feature flags into user configuration without overwriting existing settings.
*/
mergeMissingFeatures,
}; };

View File

@@ -1,12 +1,11 @@
{ {
"autoUpdate": true,
"systemTray": false, "systemTray": false,
"discordRPC": true, "discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true, "autoCheckInfo": true,
"addThumbnail": true, "addThumbnail": true,
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoUpdateYtDlp": true,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false, "keepPlaylistOrder": false,
"createPlaylistFolders": false, "createPlaylistFolders": false,
@@ -16,5 +15,6 @@
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true, "notifySystem": true,
"enableHardwareAcceleration": true, "enableHardwareAcceleration": true,
"showChanglogsAfterUpdate": true,
"theme": "dark" "theme": "dark"
} }

View File

@@ -145,7 +145,12 @@ app.whenReady().then(async () => {
if (!devMode && !checkNativeDependencies()) return; if (!devMode && !checkNativeDependencies()) return;
const { userYtDlp } = require("./server/helpers/path.helpers"); const { userYtDlp } = require("./server/helpers/path.helpers");
if (configFeatures.autoUpdateYtDlp !== false) {
updateYtDlp(userYtDlp); updateYtDlp(userYtDlp);
} else {
logger.info("yt-dlp automatic updates skipped.");
}
try { try {
setSplashProgress(0); // Checking dependencies setSplashProgress(0); // Checking dependencies
@@ -173,7 +178,7 @@ app.whenReady().then(async () => {
if (configFeatures.discordRPC) startRPC(); if (configFeatures.discordRPC) startRPC();
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow()); initAutoUpdater(getMainWindow());
} catch (err) { } catch (err) {
logger.error("Boot error:", err); logger.error("Boot error:", err);

View File

@@ -2,7 +2,7 @@
"name": "freedom-loader", "name": "freedom-loader",
"desktopName": "com.masteracnolo.freedomloader", "desktopName": "com.masteracnolo.freedomloader",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"version": "1.6.4", "version": "1.6.5",
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>", "author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms", "description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
"homepage": "https://freedomloader.acnolo.fr/", "homepage": "https://freedomloader.acnolo.fr/",
@@ -67,7 +67,6 @@
}, },
"build": { "build": {
"appId": "com.masteracnolo.freedomloader", "appId": "com.masteracnolo.freedomloader",
"artifactName": "Freedom-Loader-${version}.${ext}",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"publish": [ "publish": [
{ {
@@ -86,7 +85,12 @@
"config.js", "config.js",
"main.js", "main.js",
"preload.js", "preload.js",
"package.json" "package.json",
"!node_modules/**/README",
"!node_modules/**/*.md",
"!node_modules/**/*.map",
"!**/test/**",
"!**/__tests__/**"
], ],
"directories": { "directories": {
"buildResources": "build" "buildResources": "build"
@@ -176,6 +180,9 @@
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml" "./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
] ]
}, },
"appImage": {
"artifactName": "Freedom-Loader-${version}.${ext}"
},
"snap": { "snap": {
"confinement": "strict", "confinement": "strict",
"plugs": [ "plugs": [

View File

@@ -159,6 +159,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("update-downloaded", (_, info) => callback(info)), ipcRenderer.on("update-downloaded", (_, info) => callback(info)),
openReleasePage: () => ipcRenderer.send("open-release-page"), openReleasePage: () => ipcRenderer.send("open-release-page"),
/**
* Subscribes to toast notifications emitted by the main process.
*
* @param {Function} callback - Function executed with toast data.
* @returns {Function} Removes the listener.
*/
onToast: (callback) => {
const listener = (_, toast) => callback(toast);
ipcRenderer.on("show-toast", listener);
return () => ipcRenderer.removeListener("show-toast", listener);
},
}); });
/** /**

View File

@@ -124,10 +124,10 @@
<summary>Advanced Settings</summary> <summary>Advanced Settings</summary>
<div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="autoUpdate" id="autoUpdate"> <input type="checkbox" data-key="showChanglogsAfterUpdate" id="showChanglogsAfterUpdate">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Auto Update</span> <span class="setting-title">Show Changelogs After Update</span>
<small>Enable automatic app updates.</small> <small>Display changelogs after each update.</small>
</div> </div>
</div> </div>
@@ -135,7 +135,7 @@
<input data-key="systemTray" id="systemTray" type="checkbox"> <input data-key="systemTray" id="systemTray" type="checkbox">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">System Tray</span> <span class="setting-title">System Tray</span>
<small>Minimize app on close, in the System Tray. [EXPERIMENTAL]</small> <small>Minimize app on close, in the System Tray.</small>
</div> </div>
</div> </div>
@@ -171,13 +171,13 @@
</div> </div>
</div> </div>
<div class="setting-item"> <!-- <div class="setting-item">
<input type="checkbox" data-key="customTopBar" id="customTopBar"> <input type="checkbox" data-key="customTopBar" id="customTopBar">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Custom Top bar</span> <span class="setting-title">Custom Top bar</span>
<small>Use this if the topbar is missing or you prefer a custom one.</small> <small>Use this if the topbar is missing or you prefer a custom one.</small>
</div> </div>
</div> </div> -->
<div class="setting-item" style="margin-left: 32px; margin-top: 20px;"> <div class="setting-item" style="margin-left: 32px; margin-top: 20px;">
<div class="setting-info"> <div class="setting-info">
@@ -217,6 +217,14 @@
</div> </div>
</div> </div>
<div class="setting-item">
<input data-key="autoUpdateYtDlp" id="autoUpdateYtDlp" type="checkbox">
<div class="setting-info">
<span class="setting-title">Automatically Update yt-dlp</span>
<small>Check for and install yt-dlp updates when the application starts.</small>
</div>
</div>
</details> </details>
<div class="settings-footer"> <div class="settings-footer">

7
public/script/changelog.js Normal file → Executable file
View File

@@ -226,10 +226,15 @@ async function closeChangelog() {
/** Loads changelog data and opens the modal when unread releases exist. */ /** Loads changelog data and opens the modal when unread releases exist. */
async function loadChangelog() { async function loadChangelog() {
const features = await window.electronAPI.getFeatures();
try { try {
changelogData = await window.electronAPI.getChangelog(); changelogData = await window.electronAPI.getChangelog();
if (!changelogData.error && changelogData.hasUnread) {
if (!changelogData.error && changelogData.hasUnread && features.showChanglogsAfterUpdate === true) {
openChangelog(changelogData.unreadReleases); openChangelog(changelogData.unreadReleases);
window.electronAPI.logInfo("Showing Changelog");
} }
} catch (error) { } catch (error) {
console.error("Unable to load changelog:", error); console.error("Unable to load changelog:", error);

View File

@@ -53,17 +53,32 @@ form.addEventListener("submit", async (e) => {
const formData = new FormData(form); const formData = new FormData(form);
const params = new URLSearchParams(formData); const params = new URLSearchParams(formData);
const body = Object.fromEntries(params.entries());
try { try {
const res = await fetch("/download", { const res = await fetch("/download", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {"Content-Type": "application/json"},
body: params, body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
if (!userStoppedDownload) { if (!userStoppedDownload) {
const errorText = await res.text(); let errorText = "Server Error";
try {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const json = await res.json();
errorText =
(json && json.error && json.error.message) ||
(json && json.message) ||
errorText;
} else {
errorText = await res.text();
}
} catch (e) {
// ignore
}
window.showError(errorText); window.showError(errorText);
resetProgressBar(); resetProgressBar();
} }
@@ -71,19 +86,27 @@ form.addEventListener("submit", async (e) => {
} }
// Download successful - check if notifications are enabled // Download successful - check if notifications are enabled
try {
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
if (features.notifySystem) { if (features.notifySystem) {
window.showSuccess("Download completed!"); window.showSuccess("Download completed!");
} }
} catch (e) {
window.showSuccess("Download completed!");
}
} catch (err) { } catch (err) {
if (!isDownloading && !userStoppedDownload) { if (!isDownloading && !userStoppedDownload) {
statusDiv.style.display = "none"; statusDiv.style.display = "none";
} }
if (!userStoppedDownload) {
window.showError("Network error while starting download");
resetProgressBar();
}
} finally { } finally {
isDownloading = false; isDownloading = false;
button.disabled = false; button.disabled = false;
stopBtn.style.display = "none"; if (stopBtn) stopBtn.style.display = "none";
} }
}); });
@@ -135,7 +158,7 @@ async function stopDownload() {
try { try {
await fetch("/download/cancel", { await fetch("/download/cancel", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {"Content-Type": "application/json"},
}); });
window.showWarning("Download stopped"); window.showWarning("Download stopped");

View File

@@ -32,14 +32,27 @@ async function fetchVideoInfo(url) {
try { try {
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, { const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
method: "GET", method: "GET",
headers: {"Accept": "application/json"} headers: {Accept: "application/json"},
}); });
const data = await res.json(); let data;
try {
data = await res.json();
} catch (e) {
return {error: "Network or JSON Issue"};
}
if (!res.ok) return {error: `An Error occured when fetching info`}; if (!res.ok) {
if (data && data.error) {
return {error: data.error.message || "An Error occured when fetching info"};
}
return {error: "An Error occured when fetching info"};
}
if (!data) return { error: "Data is Missing" }; if (!data) return { error: "Data is Missing" };
if (data.error) {
return {error: data.error.message || data.error};
}
return data; return data;
} catch (e) { } catch (e) {

View File

@@ -83,3 +83,12 @@ window.showWarning = (message, duration = 4000) =>
*/ */
window.showInfo = (message, duration = 4000) => window.showInfo = (message, duration = 4000) =>
window.showToast(message, "info", duration); window.showToast(message, "info", duration);
/**
* Listens for toast notifications from the main process and displays them in the UI.
*/
window.electronAPI?.onToast(({message, type = "info", duration = 4000} = {}) => {
if (typeof message === "string") {
window.showToast(message, type, duration);
}
});

View File

@@ -35,25 +35,26 @@ export function initTopBar() {
} }
async function attachListeners(){ //* Disabled because i disabled native topbar for now, but keeping the code in case i want to re-enable it later
try{ // async function attachListeners(){
const featuresList = await window.electronAPI.getFeatures(); // try{
// const featuresList = await window.electronAPI.getFeatures();
if (!featuresList.customTopBar) { // if (!featuresList.customTopBar) {
const topbar = document.getElementById("topbar"); // const topbar = document.getElementById("topbar");
const container = document.getElementById("container"); // const container = document.getElementById("container");
const themeSwitcher = document.getElementById("theme-switcher"); // const themeSwitcher = document.getElementById("theme-switcher");
if (topbar) topbar.style.display = "none"; // if (topbar) topbar.style.display = "none";
if (container) container.style.marginTop = "0"; // if (container) container.style.marginTop = "0";
if (themeSwitcher) themeSwitcher.style.top = "30px"; // if (themeSwitcher) themeSwitcher.style.top = "30px";
} // }
} catch (error) { // } catch (error) {
console.error("Failed to load layout features for topbar:", error); // console.error("Failed to load layout features for topbar:", error);
} // }
} // }
initTopBar() initTopBar()
document.addEventListener("DOMContentLoaded", attachListeners) //document.addEventListener("DOMContentLoaded", attachListeners)

View File

@@ -1,21 +1,24 @@
### Features ### Features
- **In-App Feedback**: Added a built-in feedback form allowing users to send feedback, bug reports, and daily logs directly to the development team. - **In-App Feedback**: Added a built-in feedback form allowing users to send feedback, bug reports, and daily logs directly to the development team.
- **Community Contributions**: GitHub issues are now better maintained on the repository. If you want to contribute to the project, feel free to drop by and check out the `good first issue` tags! - **Community Contributions**: GitHub issues are now better maintained on the repository. If you want to contribute to the project, feel free to drop by and check out the `good first issue` tags!
- **Changelog**: The changelog is now automatically displayed after an update (can be disabled if preferred).
### Improvements ### Improvements
- **Bundle Size**: Optimized assets and dependencies, reducing the overall application bundle size by approximately 50% on specific version. - **Bundle Size**: Optimized assets and dependencies, reducing the overall application bundle size by approximately 30% for specific versions.
- **Updater UI**: Reworked the update notification for better clarity. Windows and AppImage users can download updates seamlessly in-app, while unsupported Linux package users are gracefully redirected to the manual download page. Flatpak (.rpm) and Snap (.snap) users are not informed, because that updates are handled by their respective package managers. - **Updater UI**: Reworked the update notification for better clarity. Windows and AppImage users can download updates seamlessly in-app, while unsupported Linux package users are gracefully redirected to the manual download page. Flatpak and Snap users are not prompted, as updates are handled natively by their respective package managers.
- **Toast Notifications**: Refactored the style and overall UX of toast alerts. - **Toast Notifications**: Refactored the style and overall UX of toast alerts.
- **Code Quality**: Implemented a linter to enforce coding standards. - **Code Quality**: Implemented a linter to enforce coding standards.
### Bug Fixes ### Fixes
- **Auto-Updater**: Fixed a major issue causing 404 Not Found errors when attempting to download updates for `.AppImage` releases. - **Auto-Updater**: Fixed a major issue causing `404 Not Found` errors when attempting to download updates for `.AppImage` releases.
- **Linux Updates**: Fixed update crashes on `.rpm` and `.snap` packages. In-app update flows are now disabled for these formats to let the OS natively handle the process. - **Linux Updates**: Fixed update crashes on `.rpm` and `.snap` packages. In-app update flows are now disabled for these formats to let the OS handle the process natively.
- **Windows Logs**: Fixed a file naming issue with the log transport system on Windows. - **Windows Logs**: Fixed a file naming issue with the log transport system on Windows.
- **Desktop Entry Name**: Fixed the desktop entry name for Linux users, ensuring it displays correctly in application menus. - **Desktop Entry Name**: Fixed the desktop entry name for Linux users, ensuring it displays correctly in application menus.
- **Custom Top Bar**: Removed the `customTopBar` settings.
- **Auto Update**: Removed the `autoAppUpdate` settings.
- **UI (System Tray)**: Removed the ***[EXPERIMENTAL]*** label from the config panel.
### Chores ### Chores
- **Dependencies**: Removed `dotenv` from the project. - **Dependencies**: Removed `dotenv` from the project.
- **URL**: A new URL for the website is now available ! [freedomloader.acnolo.fr](https://freedomloader.acnolo.fr) - **Website**: A brand new website URL is now available: [freedomloader.acnolo.fr](https://freedomloader.acnolo.fr)
- **Dev**: Add new npm script. To check linux file quality - **Dev**: Added a new npm script to check Linux file quality.

View File

@@ -132,7 +132,7 @@ if [ "$DRY_RUN" = false ]; then
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \ "$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \ "$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \ "$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
"$ROOT_DIR/dist/Freedom-Loader_${VERSION}_amd64.deb" \ "$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \ "$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \ "$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
"$ROOT_DIR/dist/latest.yml" \ "$ROOT_DIR/dist/latest.yml" \

View File

@@ -1,15 +1,23 @@
const EventEmitter = require("events");
const { fetchDownload, cancelDownload } = require("../services/download.services"); const { fetchDownload, cancelDownload } = require("../services/download.services");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl, isSafePath } = require("../helpers/validation.helpers"); const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
const { notifyDownloadFinished } = require("../helpers/notify.helpers"); const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const { configFeatures } = require("../../config"); const { configFeatures } = require("../../config");
const progressEmitter = new EventEmitter();
const speedEmitter = new EventEmitter();
const stageEmitter = new EventEmitter();
const playlistEmitter = new EventEmitter();
progressEmitter.setMaxListeners(0);
speedEmitter.setMaxListeners(0);
stageEmitter.setMaxListeners(0);
playlistEmitter.setMaxListeners(0);
/** /**
* Active Server-Sent Event subscribers receiving * Active Server-Sent Event subscribers receiving
* download progress updates. * download progress updates.
*
* Each listener receives a percentage value between
* 0 and 100 during the download lifecycle.
*/ */
const progressListeners = []; const progressListeners = [];
@@ -31,6 +39,13 @@ const stageListeners = [];
*/ */
const playlistListeners = []; const playlistListeners = [];
/**
* Set of allowed quality values for downloads.
* Used to validate user input and prevent invalid
* quality options from being passed to the download service.
*/
const ALLOWED_QUALITIES = new Set(["best", "medium", "worst", "1080", "720", "480"]);
/** /**
* Handles download requests and starts a yt-dlp download. * Handles download requests and starts a yt-dlp download.
* *
@@ -47,34 +62,91 @@ const playlistListeners = [];
*/ */
async function downloadController(req, res) { async function downloadController(req, res) {
try { try {
const body = req.body || {};
const options = { const options = {
url: req.body.url, url: body.url,
audioOnly: req.body.audioOnly === "1", audioOnly: body.audioOnly === "1" || body.audioOnly === true,
quality: req.body.quality || "best", quality: ALLOWED_QUALITIES.has(body.quality) ? body.quality : "best",
outputFolder: req.body.savePath || undefined, outputFolder: body.savePath || body.outputFolder || undefined,
playlistTitle: req.body.playlistTitle || undefined, playlistTitle: body.playlistTitle || undefined,
}; };
if (!options.url || !isValidUrl(options.url)) return res.status(400).send("Invalid URL !"); if (!options.url || typeof options.url !== "string" || !isValidUrl(options.url)) {
return res.status(400).json({
error: {code: "INVALID_URL", message: "Invalid URL"},
});
}
if (options.outputFolder && !isSafePath(options.outputFolder)) { if (options.outputFolder && !isSafePath(options.outputFolder)) {
logger.warn(`Unsafe download path rejected: ${options.outputFolder}`); logger.warn(`Unsafe download path rejected: ${options.outputFolder}`);
return res.status(400).send("Save Path Not Allowed."); return res.status(400).json({
error: {code: "INVALID_PATH", message: "Save path not allowed"},
});
} }
const progressHandler = (percent) => {
progressEmitter.emit("progress", percent);
for (let i = progressListeners.length - 1; i >= 0; i -= 1) {
try {
progressListeners[i](percent);
} catch (err) {
logger.debug(`progress listener error: ${err.message}`);
}
}
};
const speedHandler = (speed) => {
speedEmitter.emit("speed", speed);
for (let i = speedListeners.length - 1; i >= 0; i -= 1) {
try {
speedListeners[i](speed);
} catch (err) {
logger.debug(`speed listener error: ${err.message}`);
}
}
};
const stageHandler = (stage) => {
stageEmitter.emit("stage", stage);
for (let i = stageListeners.length - 1; i >= 0; i -= 1) {
try {
stageListeners[i](stage);
} catch (err) {
logger.debug(`stage listener error: ${err.message}`);
}
}
};
const playlistHandler = (info) => {
playlistEmitter.emit("playlist", info);
for (let i = playlistListeners.length - 1; i >= 0; i -= 1) {
try {
playlistListeners[i](info);
} catch (err) {
logger.debug(`playlist listener error: ${err.message}`);
}
}
};
const outputFolder = await fetchDownload( const outputFolder = await fetchDownload(
options, options,
progressListeners, [progressHandler],
speedListeners, [speedHandler],
stageListeners, [stageHandler],
playlistListeners [playlistHandler]
); );
notifyDownloadFinished(outputFolder, configFeatures.notifySystem); notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
res.send("Download Done !"); return res.json({success: true, message: "Download Done !", outputFolder});
} catch (err) { } catch (err) {
logger.error(`Server Error in /download: ${err?.message ?? err}`); logger.error(`Server Error in /download: ${err?.message ?? err}`);
res.status(500).send("Server Error"); if (err && err.message === "Download already in progress") {
return res.status(409).json({
error: {code: "DOWNLOAD_IN_PROGRESS", message: "A download is already in progress"},
});
}
return res.status(500).json({
error: {code: "DOWNLOAD_FAILED", message: "Server Error"},
});
} }
} }
@@ -87,17 +159,33 @@ async function downloadController(req, res) {
*/ */
function progressController(req, res) { function progressController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', Connection: "keep-alive",
"X-Accel-Buffering": "no",
}); });
res.flushHeaders?.();
const sendProgress = percent => res.write(`data: ${percent}\n\n`); const sendProgress = (percent) => {
try {
res.write(`data: ${percent}\n\n`);
} catch (err) {
// ignore broken pipe
}
};
progressListeners.push(sendProgress); progressListeners.push(sendProgress);
const onProgress = (percent) => sendProgress(percent);
progressEmitter.on("progress", onProgress);
req.on('close', () => { req.on("close", () => {
progressListeners.splice(progressListeners.indexOf(sendProgress), 1); const index = progressListeners.indexOf(sendProgress);
if (index >= 0) progressListeners.splice(index, 1);
progressEmitter.removeListener("progress", onProgress);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -110,17 +198,33 @@ function progressController(req, res) {
*/ */
function speedController(req, res) { function speedController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', Connection: "keep-alive",
"X-Accel-Buffering": "no",
}); });
res.flushHeaders?.();
const sendSpeed = speed => res.write(`data: ${speed}\n\n`); const sendSpeed = (speed) => {
try {
res.write(`data: ${speed}\n\n`);
} catch (err) {
// ignore broken pipe
}
};
speedListeners.push(sendSpeed); speedListeners.push(sendSpeed);
const onSpeed = (speed) => sendSpeed(speed);
speedEmitter.on("speed", onSpeed);
req.on('close', () => { req.on("close", () => {
speedListeners.splice(speedListeners.indexOf(sendSpeed), 1); const index = speedListeners.indexOf(sendSpeed);
if (index >= 0) speedListeners.splice(index, 1);
speedEmitter.removeListener("speed", onSpeed);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -134,17 +238,33 @@ function speedController(req, res) {
*/ */
function stageController(req, res) { function stageController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', Connection: "keep-alive",
"X-Accel-Buffering": "no",
}); });
res.flushHeaders?.();
const sendStage = stage => res.write(`data: ${stage}\n\n`); const sendStage = (stage) => {
try {
res.write(`data: ${stage}\n\n`);
} catch (err) {
// ignore broken pipe
}
};
stageListeners.push(sendStage); stageListeners.push(sendStage);
const onStage = (stage) => sendStage(stage);
stageEmitter.on("stage", onStage);
req.on('close', () => { req.on("close", () => {
stageListeners.splice(stageListeners.indexOf(sendStage), 1); const index = stageListeners.indexOf(sendStage);
if (index >= 0) stageListeners.splice(index, 1);
stageEmitter.removeListener("stage", onStage);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
@@ -160,10 +280,12 @@ function cancelDownloadController(req, res) {
const cancelled = cancelDownload(); const cancelled = cancelDownload();
if (cancelled) { if (cancelled) {
logger.info("Download and queue cancelled by user"); logger.info("Download and queue cancelled by user");
res.send("Download stopped! Queue cleared."); return res.json({success: true, message: "Download stopped! Queue cleared."});
} else { } else {
logger.warn("No download to cancel"); logger.warn("No download to cancel");
res.status(400).send("No download in progress !"); return res.status(400).json({
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
});
} }
} }
@@ -181,18 +303,42 @@ function cancelDownloadController(req, res) {
*/ */
function playlistInfoController(req, res) { function playlistInfoController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Connection': 'keep-alive', Connection: "keep-alive",
"X-Accel-Buffering": "no",
}); });
res.flushHeaders?.();
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`); const sendPlaylistInfo = (info) => {
try {
res.write(`data: ${info}\n\n`);
} catch (err) {
// ignore broken pipe
}
};
playlistListeners.push(sendPlaylistInfo); playlistListeners.push(sendPlaylistInfo);
const onPlaylist = (info) => sendPlaylistInfo(info);
playlistEmitter.on("playlist", onPlaylist);
req.on('close', () => { // Remove listener when client disconnects
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1); req.on("close", () => {
const index = playlistListeners.indexOf(sendPlaylistInfo);
if (index >= 0) playlistListeners.splice(index, 1);
playlistEmitter.removeListener("playlist", onPlaylist);
try {
res.end(); res.end();
} catch (err) {
// ignore
}
}); });
} }
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController}; module.exports = {
downloadController,
progressController,
speedController,
stageController,
cancelDownloadController,
playlistInfoController,
};

View File

@@ -1,129 +1,41 @@
const { fetchInfo } = require("../services/info.services"); const {fetchNormalizedInfo} = require("../services/info.services");
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation.helpers"); const { isValidUrl } = require("../helpers/validation.helpers");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Handles metadata retrieval for a video or playlist. * Handles metadata retrieval for a video or playlist.
* *
* This controller: * Returns normalized JSON responses. Accepts URL via query (?url=...)
* - Validates incoming URL (query or body) * or request body for flexibility.
* - 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").Request} req
* @param {import("express").Response} res * @param {import("express").Response} res
*/ */
async function infoController(req, res) { async function infoController(req, res) {
try {
const url = req.query.url || (req.body && req.body.url);
const encodedUrl = typeof url === "string" ? encodeURIComponent(url) : undefined;
// Previously, i support both POST and GET element, but it's kinda weird. So now it only support GET.
const url = req.query.url;
const encodedUrl = encodeURIComponent(url)
/**
* Basic validation:
* - URL must exist
* - Must be a string
* - Must pass custom URL validation
*/
if (!url || typeof url !== "string" || !isValidUrl(url)) { if (!url || typeof url !== "string" || !isValidUrl(url)) {
return res.status(400).send("Invalid URL Or Missing"); return res.status(400).json({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
} }
logger.info(`Info request received. RAW URL: ${url}`); logger.info(`Info request received. RAW URL: ${url}`);
if (encodedUrl) {
logger.info(`Info request received. ENCODED: ${encodedUrl}`); logger.info(`Info request received. ENCODED: ${encodedUrl}`);
}
// Lightweight heuristic to detect playlist URLs. It works for Youtube. const result = await fetchNormalizedInfo(url);
const isPlaylistUrl = isUrlPlaylist(url); return res.json(result);
logger.info(
isPlaylistUrl
? "Estimated Data Type: Playlist"
: "Estimated Data Type: Video"
);
try {
let data;
/**
* Primary metadata fetch attempt
*/
try {
data = await fetchInfo(url);
} catch (err) { } 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
logger.error(err);
}
} else {
logger.error(err);
}
}
/**
* Normalize response based on yt-dlp output type
*/
if (data._type === "playlist") {
// If the playlist got only one video, show it as a video.
if (data.entries && data.entries.length === 1) {
const singleVideo = parseVideo(data.entries[0]);
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
return res.json(singleVideo);
}
const playlist = parsePlaylist(data);
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( logger.error(
`Info controller error: ${err && err.message ? err.message : err}` `Info controller error: ${err && err.message ? err.message : err}`
); );
return res.status(500).json({
return res.status(500).send("Unable to fetch info"); error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
} }
} }

View File

@@ -67,9 +67,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
logger.info("--- CONFIGURATION ---"); logger.info("--- CONFIGURATION ---");
logger.info(`CONFIG autoUpdate: ${configFeatures.autoUpdate}`);
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`); logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`); logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`); logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`); logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);

View File

@@ -2,6 +2,7 @@ const fs = require("fs");
const os = require("os"); const os = require("os");
const notify = require("./notify.helpers"); const notify = require("./notify.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const {sendToast} = require("../../app/toastNotifier");
/** /**
* Detects the first supported browser installed on the system. * Detects the first supported browser installed on the system.
@@ -15,6 +16,7 @@ const { logger } = require("../logger");
* If no supported browser is found: * If no supported browser is found:
* - A warning is logged * - A warning is logged
* - A notification is displayed to the user * - A notification is displayed to the user
* - An in-app toast is displayed to the user
* - "firefox" is returned as a fallback to allow yt-dlp to * - "firefox" is returned as a fallback to allow yt-dlp to
* handle the error gracefully without crashing the application * handle the error gracefully without crashing the application
* *
@@ -51,6 +53,11 @@ function getUserBrowser() {
// No supported browser found => Notify User // No supported browser found => Notify User
logger.warn("No supported browser found on the system"); logger.warn("No supported browser found on the system");
notify.notifyFirefoxBrowserMissing(); notify.notifyFirefoxBrowserMissing();
sendToast(
"Firefox was not found on your system. Please install Firefox to import browser cookies.",
"error",
);
} else { } else {
logger.info("Browser found: firefox"); logger.info("Browser found: firefox");
} }

View File

@@ -1,6 +1,11 @@
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { app } = require("electron"); let app;
try {
app = require("electron").app;
} catch (e) {
app = null;
}
const config = require("../../config.js"); const config = require("../../config.js");
/** /**
@@ -16,10 +21,12 @@ const ext = isWindows ? '.exe' : ''
/** Centralized resource paths */ /** Centralized resource paths */
const resourcesPath = config.devMode const resourcesPath = config.devMode
? path.join(__dirname, "../../resources") ? path.join(__dirname, "../../resources")
: process.resourcesPath; : (process.resourcesPath || path.join(__dirname, "../../resources"));
/** Default download folder (centralized) */ /** Default download folder */
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader"); const defaultDownloadFolder = app && typeof app.getPath === "function"
? path.join(app.getPath("downloads"), "Freedom Loader")
: path.join(process.env.HOME || process.cwd(), "Downloads", "Freedom Loader");
/** /**
* Current platform: * Current platform:
@@ -149,7 +156,9 @@ const binaryPaths = {
* - default themes come from app resources * - default themes come from app resources
* - user themes are persisted in userData to survive updates * - user themes are persisted in userData to survive updates
*/ */
const userThemesPath = path.join(app.getPath("userData"), "themes"); const userThemesPath = app && typeof app.getPath === "function"
? path.join(app.getPath("userData"), "themes")
: path.join(process.env.HOME || process.cwd(), ".config", "Freedom Loader", "themes");
const defaultThemesSourcePath = path.join("theme"); const defaultThemesSourcePath = path.join("theme");
/** /**

View File

@@ -1,6 +1,5 @@
const RateLimit = require("express-rate-limit"); const RateLimit = require("express-rate-limit");
/** /**
* Rate limiter middleware used to protect public endpoints * Rate limiter middleware used to protect public endpoints
* against abusive or excessive requests. * against abusive or excessive requests.
@@ -15,10 +14,29 @@ const RateLimit = require("express-rate-limit");
const rateLimit = RateLimit({ const rateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, max: 5,
message: "Too many requests, please try again later.", message: {error: {code: "RATE_LIMITED", message: "Too many requests, please try again later."}},
statusCode: 429, standardHeaders: true,
legacyHeaders: false,
});
const downloadRateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: {error: {code: "RATE_LIMITED", message: "Too many download requests, please try again later."}},
standardHeaders: true,
legacyHeaders: false,
});
const infoRateLimit = RateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60,
message: {error: {code: "RATE_LIMITED", message: "Too many info requests, please try again later."}},
standardHeaders: true,
legacyHeaders: false,
}); });
module.exports = { module.exports = {
rateLimit rateLimit,
} downloadRateLimit,
infoRateLimit,
};

View File

@@ -1,9 +1,17 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController } = require("../controller/download.controller"); const {
downloadController,
progressController,
speedController,
stageController,
cancelDownloadController,
playlistInfoController,
} = require("../controller/download.controller");
const {downloadRateLimit} = require("../helpers/rateLimit.helpers");
router.post("/", downloadController); router.post("/", downloadRateLimit, downloadController);
router.post("/cancel", cancelDownloadController); router.post("/cancel", downloadRateLimit, cancelDownloadController);
// SSE for download progress // SSE for download progress
router.get("/progress", progressController); router.get("/progress", progressController);

View File

@@ -1,7 +1,9 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { infoController } = require("../controller/info.controller"); const { infoController } = require("../controller/info.controller");
const {infoRateLimit} = require("../helpers/rateLimit.helpers");
router.get("/", infoController); router.get("/", infoRateLimit, infoController);
router.post("/", infoRateLimit, infoController);
module.exports = router; module.exports = router;

View File

@@ -36,7 +36,7 @@ app.get("/", rateLimit, (req, res) => {
*/ */
async function startServer() { async function startServer() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const serverInstance = app.listen(config.applicationPort, () => { serverInstance = app.listen(config.applicationPort, "127.0.0.1" ,() => {
logger.info(`Express server ready at http://localhost:${config.applicationPort}`); logger.info(`Express server ready at http://localhost:${config.applicationPort}`);
resolve(serverInstance); resolve(serverInstance);
}); });
@@ -53,11 +53,13 @@ async function startServer() {
*/ */
function stopServer() { function stopServer() {
if (serverInstance) { if (serverInstance) {
try{ try {
serverInstance.close(); serverInstance.close(() => {
logger.info("Express server closed cleanly"); logger.info("Express server closed cleanly");
} catch (err){ });
logger.error("Express server was not closed cleanly:", err) serverInstance = null;
} catch (err) {
logger.error("Express server was not closed cleanly:", err);
} }
} else { } else {
logger.info("Express server was already closed"); logger.info("Express server was already closed");

View File

@@ -121,9 +121,11 @@ function createPlaylistFolder(basePath, playlistTitle) {
* download has completed successfully. * download has completed successfully.
*/ */
function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) { function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) {
if (currentDownloadProcess && !currentDownloadProcess.killed) {
return Promise.reject(new Error("Download already in progress"));
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const userConfig = reloadFeatures(); const userConfig = reloadFeatures();
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`); logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
@@ -171,7 +173,8 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
logger.info(`[yt-dlp args] ${args.join(" ")}`); logger.info(`[yt-dlp args] ${args.join(" ")}`);
const child = execFile(userYtDlp, args); // If we are in a non-Windows environment, we can use detached mode to allow killing the entire process group.
const child = execFile(userYtDlp, args, {detached: process.platform !== "win32"});
currentDownloadProcess = child; currentDownloadProcess = child;
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`))); child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
@@ -258,21 +261,36 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
function cancelDownload() { function cancelDownload() {
if (currentDownloadProcess) { if (currentDownloadProcess) {
logger.info("Cancelling download and killing all child processes..."); logger.info("Cancelling download and killing all child processes...");
const pid = currentDownloadProcess.pid;
// Force kill the process and all its children with SIGKILL
try { try {
// Try to kill with SIGKILL on Windows (process group) or Unix if (process.platform === "win32") {
if (process.platform === 'win32') { if (pid) {
// On Windows, kill the process tree require("child_process").execSync(`taskkill /PID ${pid} /T /F`, {
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' }); stdio: "ignore",
});
}
} else { } else {
// On Unix, send SIGKILL to process group if (pid) {
process.kill(-currentDownloadProcess.pid, 'SIGKILL'); try {
process.kill(-pid, "SIGKILL");
} catch (err) {
logger.warn(`Error killing process group: ${err.message}`);
currentDownloadProcess.kill("SIGKILL");
}
} else {
currentDownloadProcess.kill("SIGKILL");
}
} }
} catch (err) { } catch (err) {
logger.warn(`Error killing process: ${err.message}`); logger.warn(`Error killing process: ${err.message}`);
// Fallback to regular kill try {
currentDownloadProcess.kill('SIGKILL'); if (currentDownloadProcess && !currentDownloadProcess.killed) {
currentDownloadProcess.kill("SIGKILL");
}
} catch (killErr) {
logger.warn(`Fallback kill failed: ${killErr.message}`);
}
} }
currentDownloadProcess = null; currentDownloadProcess = null;

View File

@@ -2,6 +2,8 @@ const { execFile } = require("child_process");
const { userYtDlp, denoPath } = require("../helpers/path.helpers"); const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const {getUserBrowser} = require("../helpers/getBrowser.helpers"); const {getUserBrowser} = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const {parseVideo, parsePlaylist} = require("../helpers/parseInfo.helpers");
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
/** /**
* Retrieves metadata for a video or playlist using yt-dlp. * Retrieves metadata for a video or playlist using yt-dlp.
@@ -21,33 +23,44 @@ const { logger } = require("../logger");
* *
*/ */
function fetchInfo(url) { function fetchInfo(url) {
/** /**
* Base yt-dlp arguments used to retrieve metadata * Base yt-dlp arguments used to retrieve metadata
* without downloading media files. * without downloading media files.
*/ */
const metadataArgs = [ const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`, "--js-runtimes",
`deno:${denoPath}`,
"--dump-single-json", "--dump-single-json",
"--ignore-errors", "--ignore-errors",
"--yes-playlist", "--yes-playlist",
"--cookies-from-browser",`${getUserBrowser()}`, "--cookies-from-browser",
"--extractor-args","youtube:player_client=default", `${getUserBrowser()}`,
"--extractor-args",
"youtube:player_client=default",
"--ignore-no-formats-error", "--ignore-no-formats-error",
]; ];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(
execFile(userYtDlp,[...metadataArgs , url], userYtDlp,
{ timeout: 30_000, // 30s, if more timeout [...metadataArgs, url],
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller) {
timeout: 30_000, // 30s, if more timeout
maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
},
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (stderr) { if (stderr) {
const lower = stderr.toLowerCase(); const lower = stderr?.toLowerCase();
if (lower.includes("sign in to confirm") || lower.includes("failed to decrypt") || lower.includes("could not copy")) { if (
return reject(new Error(`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`)); lower.includes("sign in to confirm") ||
lower.includes("failed to decrypt") ||
lower.includes("could not copy")
) {
return reject(
new Error(
`Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`
)
);
} }
} }
@@ -61,14 +74,66 @@ function fetchInfo(url) {
try { try {
const data = JSON.parse(stdout); const data = JSON.parse(stdout);
resolve(data); resolve(data);
} catch (e) { } catch (e) {
logger.error(`JSON Parsing Error: ${e.message}`); logger.error(`JSON Parsing Error: ${e.message}`);
reject(new Error("JSON unreadable.")); reject(new Error("JSON unreadable."));
} }
}); }
);
}); });
} }
module.exports = { fetchInfo }; /**
* Fetches and normalizes metadata for a video or playlist.
* Handles playlist fallback to single video when playlist extraction fails
* or when playlist contains only one entry.
*
* @param {string} url - Video or playlist URL
* @returns {Promise<Object>} Normalized metadata object
*/
async function fetchNormalizedInfo(url) {
if (!url || typeof url !== "string") {
throw new Error("Invalid URL");
}
const isPlaylistUrl = isUrlPlaylist(url);
let data;
try {
data = await fetchInfo(url);
} catch (err) {
if (isPlaylistUrl) {
logger.info(
`Playlist fetch failed: ${err.message}. Retrying as single video...`
);
try {
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
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;
}
} else {
throw err;
}
}
if (data && data._type === "playlist") {
if (data.entries && data.entries.length === 1) {
const singleVideo = parseVideo(data.entries[0]);
logger.info(`Playlist with 1 item converted to video: ${singleVideo.title}`);
return singleVideo;
}
const playlist = parsePlaylist(data);
logger.info(`Playlist fetched: ${playlist.title} (${playlist.count} videos)`);
return playlist;
}
const video = parseVideo(data);
logger.info(`Video fetched: ${video.title}`);
return video;
}
module.exports = {fetchInfo, fetchNormalizedInfo};

View File

@@ -1,8 +1,8 @@
const path = require("path"); const path = require("path");
jest.mock("../../server/helpers/getBrowser.helpers.js", () => jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
jest.fn(() => "firefox") getUserBrowser: jest.fn(() => "firefox"),
); }));
jest.mock("../../server/helpers/path.helpers.js", () => ({ jest.mock("../../server/helpers/path.helpers.js", () => ({
ffmpegPath: "/mock/ffmpeg", ffmpegPath: "/mock/ffmpeg",
@@ -11,7 +11,6 @@ jest.mock("../../server/helpers/path.helpers.js", () => ({
jest.mock("../../config.js", () => ({ jest.mock("../../config.js", () => ({
configFeatures: { configFeatures: {
autoUpdate: false,
discordRPC: false, discordRPC: false,
customTopBar: false, customTopBar: false,
autoCheckInfo: false, autoCheckInfo: false,

43
test/unit/config.test.js Normal file
View File

@@ -0,0 +1,43 @@
const {mergeMissingFeatures} = require("../../config");
describe("configuration migration", () => {
test("adds missing defaults and preserves user values", () => {
const result = mergeMissingFeatures(
{enabled: true, retries: 3},
{enabled: false}
);
expect(result).toEqual({
features: {enabled: false, retries: 3},
changed: true,
});
});
test("preserves unknown user-defined keys", () => {
const result = mergeMissingFeatures(
{enabled: true},
{enabled: false, customSetting: "keep me"}
);
expect(result).toEqual({
features: {enabled: false, customSetting: "keep me"},
changed: false,
});
});
test("reports no migration when all default keys exist", () => {
const result = mergeMissingFeatures(
{enabled: true, retries: 3},
{enabled: false, retries: 0}
);
expect(result.changed).toBe(false);
expect(result.features.retries).toBe(0);
});
test("rejects non-object configurations", () => {
expect(() => mergeMissingFeatures({}, [])).toThrow(
"User configuration must be a JSON object"
);
});
});

View File

@@ -0,0 +1,213 @@
jest.mock("../../server/services/download.services", () => ({
fetchDownload: jest.fn(),
cancelDownload: jest.fn(),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock("../../server/helpers/validation.helpers", () => ({
isValidUrl: jest.fn(),
isSafePath: jest.fn(),
}));
jest.mock("../../server/helpers/notify.helpers", () => ({
notifyDownloadFinished: jest.fn(),
}));
jest.mock("../../config", () => ({
configFeatures: {
notifySystem: true,
},
}));
const {fetchDownload, cancelDownload} = require("../../server/services/download.services");
const {logger} = require("../../server/logger");
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
const {notifyDownloadFinished} = require("../../server/helpers/notify.helpers");
const {
downloadController,
cancelDownloadController,
progressController,
speedController,
stageController,
playlistInfoController,
} = require("../../server/controller/download.controller");
describe("download.controller", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {body: {}, on: jest.fn()};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
write: jest.fn().mockReturnThis(),
flushHeaders: jest.fn(),
end: jest.fn(),
};
});
describe("downloadController", () => {
test("returns 400 for invalid url", async () => {
isValidUrl.mockReturnValue(false);
req.body.url = "bad-url";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid URL"},
});
});
test("returns 400 for unsafe path", async () => {
isValidUrl.mockReturnValue(true);
isSafePath.mockReturnValue(false);
req.body.url = "https://youtube.com/watch?v=123";
req.body.savePath = "C:\\Windows\\System32";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_PATH", message: "Save path not allowed"},
});
expect(logger.warn).toHaveBeenCalled();
});
test("starts download and returns success", async () => {
isValidUrl.mockReturnValue(true);
isSafePath.mockReturnValue(true);
req.body = {
url: "https://youtube.com/watch?v=123",
audioOnly: "1",
quality: "720",
savePath: "/home/user/Downloads",
playlistTitle: "My Playlist",
};
fetchDownload.mockResolvedValue("/home/user/Downloads");
await downloadController(req, res);
expect(fetchDownload).toHaveBeenCalled();
const callArgs = fetchDownload.mock.calls[0][0];
expect(callArgs.url).toBe("https://youtube.com/watch?v=123");
expect(callArgs.audioOnly).toBe(true);
expect(callArgs.quality).toBe("720");
expect(callArgs.outputFolder).toBe("/home/user/Downloads");
expect(callArgs.playlistTitle).toBe("My Playlist");
expect(notifyDownloadFinished).toHaveBeenCalledWith(
"/home/user/Downloads",
true
);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: "Download Done !",
outputFolder: "/home/user/Downloads",
});
});
test("defaults quality to best for invalid quality", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockResolvedValue("/downloads");
req.body = {url: "https://youtube.com/watch?v=123", quality: "invalid"};
await downloadController(req, res);
const callArgs = fetchDownload.mock.calls[0][0];
expect(callArgs.quality).toBe("best");
});
test("returns 409 when download already in progress", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockRejectedValue(new Error("Download already in progress"));
req.body.url = "https://youtube.com/watch?v=123";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: {code: "DOWNLOAD_IN_PROGRESS", message: "A download is already in progress"},
});
});
test("returns 500 on generic error", async () => {
isValidUrl.mockReturnValue(true);
fetchDownload.mockRejectedValue(new Error("yt-dlp failed"));
req.body.url = "https://youtube.com/watch?v=123";
await downloadController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: {code: "DOWNLOAD_FAILED", message: "Server Error"},
});
expect(logger.error).toHaveBeenCalled();
});
});
describe("cancelDownloadController", () => {
test("returns success when download cancelled", () => {
cancelDownload.mockReturnValue(true);
cancelDownloadController(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: "Download stopped! Queue cleared.",
});
expect(logger.info).toHaveBeenCalled();
});
test("returns 400 when no download in progress", () => {
cancelDownload.mockReturnValue(false);
cancelDownloadController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "NO_DOWNLOAD_IN_PROGRESS", message: "No download in progress !"},
});
expect(logger.warn).toHaveBeenCalled();
});
});
describe("SSE controllers", () => {
test("progressController sets headers and writes data", () => {
req.on = jest.fn((event, cb) => {
if (event === "close") {
// don't call immediately
}
});
progressController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
})
);
expect(res.flushHeaders).toHaveBeenCalled();
});
test("speedController sets headers", () => {
req.on = jest.fn();
speedController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
test("stageController sets headers", () => {
req.on = jest.fn();
stageController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
test("playlistInfoController sets headers", () => {
req.on = jest.fn();
playlistInfoController(req, res);
expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({
"Content-Type": "text/event-stream",
})
);
});
});
});

View File

@@ -0,0 +1,83 @@
jest.mock("../../server/services/info.services", () => ({
fetchNormalizedInfo: jest.fn(),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
jest.mock("../../server/helpers/validation.helpers", () => ({
isValidUrl: jest.fn(),
}));
const {fetchNormalizedInfo} = require("../../server/services/info.services");
const {logger} = require("../../server/logger");
const {isValidUrl} = require("../../server/helpers/validation.helpers");
const {infoController} = require("../../server/controller/info.controller");
describe("info.controller", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {query: {}, body: {}};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
});
test("returns 400 for missing url", async () => {
isValidUrl.mockReturnValue(false);
req.query.url = undefined;
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
});
test("returns 400 for invalid url", async () => {
isValidUrl.mockReturnValue(false);
req.query.url = "not-a-url";
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {code: "INVALID_URL", message: "Invalid or missing URL"},
});
});
test("accepts url from body", async () => {
isValidUrl.mockReturnValue(true);
req.body.url = "https://youtube.com/watch?v=123";
fetchNormalizedInfo.mockResolvedValue({type: "video", title: "Test"});
await infoController(req, res);
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
expect(res.json).toHaveBeenCalledWith({type: "video", title: "Test"});
});
test("returns normalized result for valid url", async () => {
isValidUrl.mockReturnValue(true);
req.query.url = "https://youtube.com/watch?v=123";
const mockResult = {type: "video", title: "My Video", duration: 120};
fetchNormalizedInfo.mockResolvedValue(mockResult);
await infoController(req, res);
expect(fetchNormalizedInfo).toHaveBeenCalledWith("https://youtube.com/watch?v=123");
expect(res.json).toHaveBeenCalledWith(mockResult);
expect(logger.info).toHaveBeenCalled();
});
test("returns 500 on service error", async () => {
isValidUrl.mockReturnValue(true);
req.query.url = "https://youtube.com/watch?v=123";
fetchNormalizedInfo.mockRejectedValue(new Error("fetch failed"));
await infoController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,161 @@
jest.mock("child_process");
jest.mock("../../server/helpers/path.helpers", () => ({
userYtDlp: "/mock/yt-dlp",
denoPath: "/mock/deno",
}));
jest.mock("../../server/helpers/getBrowser.helpers", () => ({
getUserBrowser: jest.fn(() => "firefox"),
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock("../../server/helpers/parseInfo.helpers", () => ({
parseVideo: jest.fn((data) => ({type: "video", ...data})),
parsePlaylist: jest.fn((data) => ({
type: "playlist",
title: data.title,
count: data.entries ? data.entries.length : 0,
videos: [],
})),
}));
jest.mock("../../server/helpers/playlist.helpers", () => ({
isUrlPlaylist: jest.fn(),
}));
const {execFile} = require("child_process");
const {fetchInfo, fetchNormalizedInfo} = require("../../server/services/info.services");
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
const {isUrlPlaylist} = require("../../server/helpers/playlist.helpers");
const {logger} = require("../../server/logger");
describe("info.services", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("fetchInfo", () => {
test("resolves parsed json on success", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, JSON.stringify({_type: "video", title: "Test"}), "");
});
const result = await fetchInfo("https://youtube.com/watch?v=123");
expect(result).toEqual({_type: "video", title: "Test"});
expect(execFile).toHaveBeenCalled();
});
test("rejects when cookies error in stderr", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "", "sign in to confirm you're not a bot");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
/Unable to extract cookies/
);
});
test("rejects on exec error", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(new Error("spawn failed"), "", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"Unable to fetch Info."
);
});
test("rejects when no stdout", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"No Data Received."
);
});
test("rejects on invalid json", async () => {
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, "not json", "");
});
await expect(fetchInfo("https://youtube.com/watch?v=123")).rejects.toThrow(
"JSON unreadable."
);
});
});
describe("fetchNormalizedInfo", () => {
test("normalizes video", async () => {
isUrlPlaylist.mockReturnValue(false);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(null, JSON.stringify({_type: "video", title: "My Video"}), "");
});
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123");
expect(result.type).toBe("video");
expect(parseVideo).toHaveBeenCalled();
});
test("normalizes playlist", async () => {
isUrlPlaylist.mockReturnValue(true);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(
null,
JSON.stringify({
_type: "playlist",
title: "Playlist",
entries: [{id: "1"}, {id: "2"}],
}),
""
);
});
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
expect(result.type).toBe("playlist");
expect(parsePlaylist).toHaveBeenCalled();
});
test("converts playlist with 1 entry to video", async () => {
isUrlPlaylist.mockReturnValue(true);
execFile.mockImplementation((cmd, args, opts, cb) => {
cb(
null,
JSON.stringify({
_type: "playlist",
title: "Playlist",
entries: [{id: "1", title: "Single"}],
}),
""
);
});
const result = await fetchNormalizedInfo("https://youtube.com/playlist?list=ABC");
expect(result.type).toBe("video");
expect(parseVideo).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Playlist with 1 item converted to video")
);
});
test("falls back to single video when playlist fetch fails", async () => {
isUrlPlaylist.mockReturnValue(true);
let callCount = 0;
execFile.mockImplementation((cmd, args, opts, cb) => {
callCount++;
if (callCount === 1) {
cb(new Error("fail"), "", "");
} else {
cb(null, JSON.stringify({_type: "video", title: "Fallback"}), "");
}
});
const result = await fetchNormalizedInfo("https://youtube.com/watch?v=123&list=ABC");
expect(result.type).toBe("video");
expect(callCount).toBe(2);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Retrying as single video")
);
});
test("throws when url is invalid type", async () => {
await expect(fetchNormalizedInfo(null)).rejects.toThrow("Invalid URL");
});
});
});

View File

@@ -1,6 +1,6 @@
const mockShow = jest.fn(); const mockShow = jest.fn();
const mockOn = jest.fn(); const mockOn = jest.fn();
const mockOpenPath = jest.fn(); const mockOpenPath = jest.fn().mockResolvedValue("");
const mockOpenExternal = jest.fn(); const mockOpenExternal = jest.fn();
jest.mock("electron", () => ({ jest.mock("electron", () => ({
@@ -71,13 +71,16 @@ describe("notify.helpers", () => {
expect(mockShow).toHaveBeenCalled(); expect(mockShow).toHaveBeenCalled();
}); });
test("opens folder when notification is clicked", () => { test("opens folder when notification is clicked", (done) => {
notifyDownloadFinished("/downloads"); notifyDownloadFinished("/downloads");
const callback = mockOn.mock.calls[0][1]; const callback = mockOn.mock.calls[0][1];
callback(); callback();
setTimeout(() => {
expect(shell.openPath).toHaveBeenCalledWith("/downloads"); expect(shell.openPath).toHaveBeenCalledWith("/downloads");
done();
}, 200);
}); });
}); });
@@ -101,7 +104,7 @@ describe("notify.helpers", () => {
callback(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" "https://www.firefox.com/en-US/download/"
); );
}); });
}); });
@@ -126,7 +129,7 @@ describe("notify.helpers", () => {
callback(); callback();
expect(shell.openExternal).toHaveBeenCalledWith( expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1" "https://www.firefox.com/en-US/download/"
); );
}); });
}); });

View File

@@ -0,0 +1,66 @@
const {parseVideo, parsePlaylist} = require("../../server/helpers/parseInfo.helpers");
describe("parseInfo.helpers", () => {
describe("parseVideo", () => {
test("adds type video and spreads data", () => {
const data = {title: "Test Video", duration: 120, id: "abc123"};
const result = parseVideo(data);
expect(result.type).toBe("video");
expect(result.title).toBe("Test Video");
expect(result.duration).toBe(120);
expect(result.id).toBe("abc123");
});
});
describe("parsePlaylist", () => {
test("normalizes playlist with entries", () => {
const data = {
title: "My Playlist",
uploader: "Test Channel",
entries: [
{
id: "vid1",
title: "Video 1",
webpage_url: "https://example.com/watch?v=vid1",
duration: 60,
thumbnail: "thumb1.jpg",
uploader: "Uploader1",
},
{
id: "vid2",
title: "Video 2",
webpage_url: "https://example.com/watch?v=vid2",
duration: 90,
thumbnail: "thumb2.jpg",
uploader: "Uploader2",
},
],
};
const result = parsePlaylist(data);
expect(result.type).toBe("playlist");
expect(result.title).toBe("My Playlist");
expect(result.channel).toBe("Test Channel");
expect(result.count).toBe(2);
expect(result.videos).toHaveLength(2);
expect(result.videos[0]).toEqual({
id: "vid1",
title: "Video 1",
url: "https://example.com/watch?v=vid1",
duration: 60,
thumbnail: "thumb1.jpg",
uploader: "Uploader1",
});
});
test("falls back to id when title missing", () => {
const data = {
id: "PL123",
entries: [],
};
const result = parsePlaylist(data);
expect(result.title).toBe("PL123");
expect(result.count).toBe(0);
expect(result.videos).toEqual([]);
});
});
});

View File

@@ -0,0 +1,38 @@
const mockSend = jest.fn();
const mockGetMainWindow = jest.fn();
jest.mock("../../app/windowManager", () => ({
getMainWindow: mockGetMainWindow,
}));
const {sendToast} = require("../../app/toastNotifier");
describe("toastNotifier", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("sends a toast to the active renderer", () => {
mockGetMainWindow.mockReturnValue({
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
send: mockSend,
},
});
expect(sendToast("Firefox is missing", "error", 5000)).toBe(true);
expect(mockSend).toHaveBeenCalledWith("show-toast", {
message: "Firefox is missing",
type: "error",
duration: 5000,
});
});
test("does not throw when the renderer is unavailable", () => {
mockGetMainWindow.mockReturnValue(null);
expect(sendToast("Firefox is missing", "error")).toBe(false);
expect(mockSend).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
const {isValidUrl, isSafePath} = require("../../server/helpers/validation.helpers");
describe("validation.helpers", () => {
describe("isValidUrl", () => {
test("returns true for valid http url", () => {
expect(isValidUrl("http://example.com")).toBe(true);
});
test("returns true for valid https url", () => {
expect(isValidUrl("https://youtube.com/watch?v=123")).toBe(true);
});
test("returns false for invalid url", () => {
expect(isValidUrl("not-a-url")).toBe(false);
});
test("returns false for empty string", () => {
expect(isValidUrl("")).toBe(false);
});
});
describe("isSafePath", () => {
test("returns false for empty or short path", () => {
expect(isSafePath("")).toBe(false);
expect(isSafePath("ab")).toBe(false);
});
test("returns false for Windows System32 path", () => {
expect(isSafePath("C:\\Windows\\System32\\test")).toBe(false);
});
test("returns false for Windows path containing Windows", () => {
expect(isSafePath("C:\\Windows\\SomeFolder")).toBe(false);
});
test("returns true for safe user path", () => {
expect(isSafePath("/home/user/Downloads")).toBe(true);
});
});
});