Compare commits

..

37 Commits

Author SHA1 Message Date
MasterAcnolo
2a9b4420de fix(tray): dynamically create/destroy tray icon on setting toggle
Previously, toggling the system tray in the settings updated the configuration but did not instantiate or destroy the tray immediately. This caused a desynchronization bug where the application could minimize to a non-existent tray, creating a ghost instance.

The `set-feature` IPC handler now intercepts the `systemTray` key to apply the state at runtime before persisting the config.
2026-08-14 22:11:56 +02:00
MasterAcnolo
e521747baa feat: custom signature in every file download, as a comment 2026-08-14 22:08:41 +02:00
MasterAcnolo
2d71449a10 fix: tray behaviror. App will shutdown gracefully 2026-08-14 22:08:23 +02:00
MasterAcnolo
0a152d4b10 fix: rename "listeners" in "progressListeners", that enhance readability 2026-08-14 22:05:12 +02:00
MasterAcnolo
676607b2c9 fix: disable systemTray by default. Could be reenable by default on a next release. 2026-08-14 21:39:13 +02:00
MasterAcnolo
def3ced215 update: package-lock.json 2026-08-14 17:22:31 +02:00
MasterAcnolo
febe8364cd fix: log session date format (W Sweden) 2026-08-14 17:22:07 +02:00
MasterAcnolo
25d3b6e8e7 fix: false warn about DownloadPath at startup 2026-08-14 17:18:28 +02:00
MasterAcnolo
7ab23d495c feat: system tray. Working on Windows, should be tested on Linux (GNOME) env. More features to systemTray coming soon 2026-08-13 21:46:22 +02:00
MasterAcnolo
331890f743 fix: missing config reference in main.js and tray.js 2026-08-13 19:10:36 +02:00
MasterAcnolo
e398f6866a feat: allow app to minimize on the systemTray. Could be disable on purpose. 2026-08-09 09:58:55 +02:00
MasterAcnolo
5f28aa502a fix: discord RPC on Linux, creating symlink between Discord and Freedom Loader at start if needed 2026-08-08 10:51:10 +02:00
MasterAcnolo
4a10ef2614 refactor: app display in front end. To make difference more obvious between bundled and not 2026-08-08 10:50:39 +02:00
MasterAcnolo
1296199f65 add: somes startup configuration for dev 2026-08-08 10:50:18 +02:00
MasterAcnolo
764b566699 update: bump version to 1.6.2 2026-08-08 10:12:21 +02:00
MasterAcnolo
0c568c1e8d fix: small fixes in README and update project structure 2026-08-08 10:12:06 +02:00
MasterAcnolo
dae4dd852f add: nodemon configuration for developpment env 2026-08-08 09:28:03 +02:00
MasterAcnolo
e5c5542750 add: nodemon configuration for developpment env 2026-08-08 09:28:01 +02:00
MasterAcnolo
9b2c6c9552 fix: release.sh version format 2026-08-07 23:28:24 +02:00
MasterAcnolo
871ebe2498 refactor: enhance log format by adding color when app is run in an IDE or a Terminal 2026-08-07 23:28:00 +02:00
Axel Nicolas
730069a3c6 Merge pull request #46 from MasterAcnolo/1.6.1
1.6.1
2026-08-07 22:27:33 +02:00
MasterAcnolo
7a0cbd7456 refactor: changed /info router from POST to GET 2026-08-07 21:13:12 +02:00
MasterAcnolo
5d676b45e7 refactor: moved "Auto Fetch Info" settings to advanced settings 2026-08-07 21:06:11 +02:00
MasterAcnolo
a7f3d84a7d fix: log start format and smalls coms fix 2026-08-07 21:04:28 +02:00
MasterAcnolo
fbce5e5b7b update: dependencies 2026-08-05 22:51:55 +02:00
MasterAcnolo
cc7cc38392 feat: automatic release creation with scripts 2026-08-05 22:51:31 +02:00
MasterAcnolo
6a9e5a063b refactor: notify.helpers.js logic. Add a timeout and a better notification management to avoid race condition on Unix Systems 2026-08-05 22:16:15 +02:00
MasterAcnolo
cf8c95711c refactor: notify.helpers.js logic. Add a timeout and a better notification management to avoid race condition on Unix Systems 2026-08-05 22:15:53 +02:00
MasterAcnolo
3683e4245f fix: enhance readme with keepPlaylistOrder information. And fix some typo to fix Linux Env 2026-08-05 22:10:44 +02:00
MasterAcnolo
01affd8ddf feat: Keep Playlist Order features. Add index before title if it's a playlist and we activate this feature. 2026-08-05 21:52:14 +02:00
MasterAcnolo
faba3f516b fix: unit test wrong folder 2026-08-05 21:49:37 +02:00
MasterAcnolo
c1d9448a2b fix: bump version to 1.6.1 and update electron to 43.3.0 2026-08-05 21:49:25 +02:00
MasterAcnolo
1bff8376b4 fix: force artifact name for exe 2026-08-04 22:04:20 +02:00
MasterAcnolo
3f44c01bd7 fix: refresh config at every download. Used by CreatePlaylistFolder 2026-08-04 22:03:55 +02:00
MasterAcnolo
d0bdd887e3 fix: exclude dist, release and build folder from jest config 2026-08-02 21:40:39 +02:00
MasterAcnolo
609ac2eadb fix: better dev mode comparaison, useful for tests 2026-08-02 09:25:39 +02:00
MasterAcnolo
c484d322bf feat: unit tests for buildArgs and Notify 2026-08-02 09:25:05 +02:00
27 changed files with 5929 additions and 2597 deletions

View File

@@ -61,9 +61,9 @@ The primary goal is to make media downloading accessible to users who want offli
### User Interface
- **Custom window controls** - Frameless window with custom top bar (optional)
- **Custom themes** — ZIP-based theme system with live preview
- **Theme Workshop** — web-based theme creator and browser
- **Toast notifications** — in-app feedback system
- **Custom themes** - ZIP-based theme system with live preview
- **Theme Workshop** - web-based theme creator and browser
- **Toast notifications** - in-app feedback system
- **Real-time progress tracking** - Live download progress bar with network speed indicator
- **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming
- **Settings panel** - In-app configuration interface with live updates
@@ -80,9 +80,10 @@ The primary goal is to make media downloading accessible to users who want offli
- **Rate limiting** - Express-based request throttling to prevent server overload
- **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections
- **System notifications** - Windows notifications on download completion (clickable to open folder)
- **Splash screen** — animated loading screen on startup
- **Stop download** — ability to cancel in-progress downloads
- **Playlist folders** — dedicated folder creation per playlist
- **Splash screen** - animated loading screen on startup
- **Stop download** - ability to cancel in-progress downloads
- **Playlist folders** - dedicated folder creation per playlist
- **Keep Playlist Order** - Add an Index before the title, could be useful for an album for example.
## Installation
@@ -105,7 +106,7 @@ The primary goal is to make media downloading accessible to users who want offli
### Linux
#### Fedora (via Copr — recommended)
#### Fedora (via Copr - recommended)
Installing via Copr lets `dnf` handle future updates automatically:
@@ -178,7 +179,8 @@ The application supports various configuration options:
- Custom download directory selection
- Discord Rich Presence activation
- Theme customization
- Log level configuration
- Log level
- And somes to see in the list [Configuration Options](#configuration-options) available
### Troubleshooting
@@ -219,13 +221,14 @@ Freedom Loader can be configured either through the settings panel in the UI or
#### Linux
```
~/.config/Freedom Loader/config.json
~/.config/Freedom\ Loader/config.json
```
### Available Options
```json
{
"autoUpdate": true,
"systemTray": true,
"discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true,
@@ -233,6 +236,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
"addMetadata": true,
"verboseLogs": false,
"autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true,
"customCodec": "h264",
"logSystem": true,
@@ -249,6 +253,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
| Option | Type | Default | Description |
|------------------------------|---------|----------|------------------------------------------------------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
| `systemTray` | boolean | `true` | Allow app to minimize on close |
| `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 |
@@ -256,6 +261,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging |
@@ -276,7 +282,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
- `theora` - Theora (legacy open codec)
> [!NOTE]
> Configuration changes may require an application restart to take effect.
> Some configuration changes may require an application restart to take effect.
## Project Structure
@@ -290,15 +296,21 @@ Freedom-Loader/
│ ├── pathValidator.js
│ ├── splashManager.js
│ ├── themeManager.js
│ ├── tray.js
│ ├── windowManager.js
│ └── ytDlpUpdater.js
├── build/ # Build resources and assets
│ ├── app-icon.ico
│ ├── app-icon.png
│ ├── banner.png
│ └── ...
├── config/ # Configuration files
│ ├── config.default.json
│ └── config.dev.json
│ └── config.dev.json # Configuration Used when i dev mode
├── dist/ # Compiled executables (.exe, .AppImage, .deb, .rpm, .snap)
├── public/ # Frontend assets and UI
│ ├── index.html
│ ├── splash.html
│ ├── splash.html # Splash Screen (Start of the application)
│ ├── assets/
│ │ ├── icon/ # Application icons
│ │ └── logo/ # Logo assets
@@ -317,19 +329,8 @@ Freedom-Loader/
│ ├── styles.css
│ ├── variables.css
│ ├── components/ # Component-specific styles
│ │ ├── checkbox.css
│ │ ├── editpathbutton.css
│ │ ├── loader.css
│ │ ├── progressBar.css
│ │ └── toast.css
│ └── layout/ # Layout styles
│ ├── container.css
│ ├── form.css
│ ├── header.css
│ ├── settingsPanel.css
│ ├── topbar.css
│ └── videoinfo.css
├── resources/ # Internal resources (binaries)
├── resources/ # Internal resources (binaries : yt-dlp, ffmpeg...)
├── server/ # Express backend server
│ ├── logger.js
│ ├── server.js
@@ -353,16 +354,16 @@ Freedom-Loader/
├── theme/ # Theme system
│ ├── template.theme.json # Theme template
│ ├── Dark/ # Default dark theme
│ │ └── dark.theme.json
│ └── Light/ # Default light theme
│ └── light.theme.json
├── main.js # Electron main process entry point
├── preload.js # Electron preload script (IPC bridge)
├── config.js # Global configuration loader
├── package.json # Project metadata and dependencies
├── BINARIES.md # External dependencies & binaries documentation
├── CODE_OF_CONDUCT.md # Community guidelines
├── config.js # Global configuration loader
├── CONTRIBUTING.md # Contribution guidelines
├── jest.config.js # Unit testing configuration
├── LICENSE # GPLv3 license
├── main.js # Electron main process entry point
├── package.json # Project metadata and dependencies
├── preload.js # Electron preload script (IPC bridge)
└── README.md # This file
```
@@ -389,7 +390,7 @@ Freedom Loader includes a web-based theme creator available at [Freedom Loader W
### Installing a custom theme
1. Download a `.zip` theme file
2. Drop it in the `theme/` folder of your Freedom Loader installation
3. Restart the app — your theme appears in the settings panel
3. Restart the app - your theme appears in the settings panel
## Technology Stack

View File

@@ -1,6 +1,8 @@
const config = require('../config');
const RPC = require("discord-rpc");
const { logger } = require("../server/logger");
const fs = require("fs");
const path = require("path");
/**
* Discord Application Client ID used to authenticate the RPC connection.
@@ -20,6 +22,51 @@ const rpc = new RPC.Client({ transport: "ipc" });
*/
let intervalId;
/**
* Automagically fixes Discord RPC on Linux for Sandboxed versions (Flatpak & Snap).
* It creates a symlink from the sandbox IPC socket to the standard system location.
*
* @returns {void}
*/
function fixLinuxDiscordRPC() {
// Only execute on Linux
if (process.platform !== "linux") return;
/**
* Get the user's runtime directory (e.g., /run/user/1000).
* @type {string}
*/
const xdgDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid()}`;
const standardSocket = path.join(xdgDir, "discord-ipc-0");
// If the standard socket already exists (Native Discord), do nothing.
if (fs.existsSync(standardSocket)) return;
/**
* Paths for known sandboxed Discord IPC sockets.
*/
const flatpakSocket = path.join(xdgDir, "app/com.discordapp.Discord/discord-ipc-0");
const snapSocket = path.join(xdgDir, "snap.discord/discord-ipc-0");
let targetSocket = null;
if (fs.existsSync(flatpakSocket)) {
targetSocket = flatpakSocket;
} else if (fs.existsSync(snapSocket)) {
targetSocket = snapSocket;
}
// If a sandboxed socket is found, create the symlink stealthily.
if (targetSocket) {
try {
fs.symlinkSync(targetSocket, standardSocket);
logger.info(`Discord RPC bridge created automatically targeting: ${targetSocket}`);
} catch (err) {
logger.warn(`Failed to create Discord RPC bridge: ${err.message}`);
}
}
}
/**
* Initializes Discord Rich Presence (RPC) connection.
*
@@ -32,6 +79,11 @@ let intervalId;
*/
function startRPC() {
/**
* Patch that create Simlinks for Linux environment;
*/
fixLinuxDiscordRPC();
/**
* Rich Presence payload describing current application state
* shown in Discord user profile.
@@ -60,28 +112,30 @@ function startRPC() {
*
* Responsibilities:
* - Clear any running intervals
* - Remove current activity from Discord
* - Remove current activity from Discord if connected
* - Destroy RPC transport connection
* - Handle cleanup errors safely
*/
async function stopRPC(){
async function stopRPC() {
if (rpc) {
try {
if (intervalId) clearInterval(intervalId);
/**
* Ensures RPC connection exists before attempting cleanup.
* Ensures RPC connection AND the underlying socket exist
* before attempting to clear activity to prevent crash.
*/
if (rpc && rpc.transport) {
await rpc.clearActivity()
await rpc.destroy();
} else{
logger.error("Not Able to close RPC")
if (rpc.transport && rpc.transport.socket) {
await rpc.clearActivity();
}
await rpc.destroy();
} catch (err) {
logger.error("Error while closing the RPC:", err);
}
}
}
module.exports = { startRPC, stopRPC};

View File

@@ -18,6 +18,7 @@ const { getThemes, reloadThemes } = require("./themeManager");
const config = require("../config");
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
const { userThemesPath } = require("../server/helpers/path.helpers");
const { createSystemTray, destroyTray } = require("./tray");
/**
* Security whitelist for feature flags that can be modified at runtime.
@@ -27,6 +28,7 @@ const { userThemesPath } = require("../server/helpers/path.helpers");
*/
const FEATURE_WHITELIST = new Set([
"autoUpdate",
"systemTray",
"discordRPC",
"customTopBar",
"autoCheckInfo",
@@ -34,6 +36,7 @@ const FEATURE_WHITELIST = new Set([
"addMetadata",
"verboseLogs",
"autoDownloadPlaylist",
"keepPlaylistOrder",
"customCodec",
"theme",
"createPlaylistFolders",
@@ -212,6 +215,21 @@ function registerIpcHandlers(getMainWindow) {
return true;
}
/**
* Dynamically intercepts and applies System Tray state changes at runtime.
* Instantiates or destroys the tray icon immediately to prevent window
* lifecycle desynchronization (e.g., minimizing a window to a non-existent tray).
*/
if (key === "systemTray") {
if (value === true) {
logger.info("System Tray enabled dynamically.");
createSystemTray(getMainWindow(), config.devMode);
} else {
logger.info("System Tray disabled dynamically.");
destroyTray();
}
}
configFeatures[key] = value;
fs.writeFileSync(

View File

@@ -56,28 +56,23 @@ function validateDownloadPath(userPath) {
if (!userPath) return getDefaultDownloadPath();
try {
const absolutePath = path.resolve(userPath);
/**
* Resolves filesystem symlinks to ensure canonical absolute path.
* Required to prevent path traversal or alias bypass.
*/
const resolved = fs.realpathSync(path.resolve(userPath));
if (!fs.existsSync(resolved)) {
fs.mkdirSync(resolved, { recursive: true });
logger.info(`Download folder created: ${resolved}`);
if (!fs.existsSync(absolutePath)) {
fs.mkdirSync(absolutePath, { recursive: true });
logger.info(`Download folder created: ${absolutePath}`);
}
const real = fs.realpathSync(resolved);
const real = fs.realpathSync(absolutePath);
if (!isSafePath(resolved)) {
if (!isSafePath(real)) {
throw new Error("Path not allowed: system folders are blocked!");
}
return real;
}
catch (err) {
catch (err) {
/**
* Handles invalid filesystem paths, permissions issues, or resolution failures.
* Logs diagnostic information before propagating a sanitized error.

108
app/tray.js Normal file
View File

@@ -0,0 +1,108 @@
const {app, Menu, Tray, nativeImage} = require("electron");
const path = require("path");
const {logger} = require("../server/logger"); // Ajuste le chemin si besoin
const fs = require("fs");
/**
* Global reference to the Tray instance to prevent garbage collection.
* @type {Tray | null}
*/
let tray = null;
/**
* Creates and configures the cross-platform System Tray.
*
* Responsibilities:
* - Loads the appropriate icon format based on the operating system.
* - Builds the right-click context menu.
* - Handles left-click behavior to toggle main window visibility.
*
* @param {import('electron').BrowserWindow} mainWindow - The main application window.
* @param {boolean} devMode - Indicates whether the application is running in development mode.
* @returns {Tray} The created Tray instance.
*/
function createSystemTray(mainWindow, devMode) {
// Prevent creating multiple tray instances
if (tray) return tray;
const isWin = process.platform === "win32";
const iconName = isWin ? "app-icon.ico" : "app-icon-64x64.png";
// Attempt to load the icon from the local build directory first
let iconPath = path.join(__dirname, "..", "build", iconName);
// Fallback to the extracted resources path if running in production
if (!devMode && !fs.existsSync(iconPath)) {
iconPath = path.join(process.resourcesPath, iconName);
}
if (!fs.existsSync(iconPath)) {
logger.error(`System Tray icon not found at resolved path: ${iconPath}`);
} else {
logger.info("System Tray icon resolved successfully.");
}
const icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) {
logger.error("System Tray icon file was found but could not be decoded by Electron (image is empty).");
}
tray = new Tray(icon);
tray.setToolTip("Freedom Loader");
/**
* System Tray Context Menu (Right-click action).
*/
const contextMenu = Menu.buildFromTemplate([
{
label: "Show Freedom Loader",
click: () => {
mainWindow.show();
},
},
{ type: "separator" },
{
label: "Quit",
click: () => {
/**
* Flags the application for a deliberate exit,
* bypassing the default "minimize to tray" behavior.
*/
app.isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
/**
* Left-click behavior: Toggles main window visibility.
* but it remains standard practice for Windows and Linux environments.
*/
tray.on("click", () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
});
return tray;
}
/**
* Safely destroys the tray icon to prevent OS-level crashes (especially on Linux/Wayland).
* Should be called right before the application quits.
*/
function destroyTray() {
if (tray && !tray.isDestroyed()) {
tray.destroy();
tray = null;
logger.info("System Tray destroyed cleanly.");
}
}
module.exports = {createSystemTray, destroyTray};

View File

@@ -81,6 +81,19 @@ async function createMainWindow() {
throw err;
}
/**
* Intercepts the window close event.
* If the app is not explicitly quitting (via Tray menu),
* cancels the destruction and hides the window instead.
*/
mainWindow.on('close', (event) => {
if (!app.isQuitting && config.systemTray ) {
event.preventDefault();
mainWindow.hide();
return false;
}
});
/**
* Cleans up window reference when the main window is closed.
* Prevents memory leaks and allows recreation if needed.

BIN
build/app-icon-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -7,7 +7,7 @@ const path = require("path");
* Indicates whether the application is running in development mode.
* Determined by Electron's packaging state.
*/
const devMode = !app.isPackaged;
const devMode = process.env.NODE_ENV === "test" || !app?.isPackaged;
/**
* Resolves the configuration file path depending on runtime environment.
@@ -68,13 +68,30 @@ function loadFeatures() {
return JSON.parse(raw);
}
/**
* Reloads feature flags from disk.
* Use this instead of the cached configFeatures snapshot
* when you need the latest user settings at call time.
*
* @returns {Object} Fresh feature flags from config file
*/
function reloadFeatures() {
try {
const raw = fs.readFileSync(featuresPath, "utf-8");
return JSON.parse(raw);
} catch (err) {
// Fallback to cached snapshot if file read fails
return configFeatures;
}
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart or reload is required.
*/
const configFeatures = loadFeatures();
let configFeatures = loadFeatures();
module.exports = {
/**
@@ -102,6 +119,11 @@ module.exports = {
*/
configFeatures,
/**
* Function to reload the config
*/
reloadFeatures,
/**
* Path to the active configuration file
*/

View File

@@ -1,5 +1,6 @@
{
"autoUpdate": true,
"systemTray": false,
"discordRPC": true,
"customTopBar": true,
"autoCheckInfo": true,
@@ -7,6 +8,7 @@
"addMetadata": true,
"verboseLogs": false,
"autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true,
"customCodec": "h264",
"logSystem": true,

21
jest.config.js Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
testEnvironment: "node",
testMatch: ["**/test/unit/**/*.test.js"],
collectCoverageFrom: [
"server/**/*.js",
"app/**/*.js",
"!app/autoUpdater.js",
],
modulePathIgnorePatterns: [
"<rootDir>/dist",
"<rootDir>/release",
"<rootDir>/build",
],
watchPathIgnorePatterns: [
"<rootDir>/dist",
"<rootDir>/release",
"<rootDir>/build",
],
};

42
main.js
View File

@@ -80,7 +80,21 @@ const { createMainWindow, getMainWindow } = require("./app/windowManager");
const { registerIpcHandlers } = require("./app/ipcHandlers");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const { userThemesPath, initUserThemes, isWindows, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
const {createSystemTray, destroyTray} = require("./app/tray");
/**
* Global flag indicating if the application is intentionally shutting down.
* Used across the app to bypass the "minimize to tray on close" behavior.
* @type {boolean}
*/
app.isQuitting = false;
/**
* State flag to track the asynchronous cleanup process during shutdown.
* Prevents the application from exiting before services (RPC, Tray, Logs) are properly closed.
* @type {boolean}
*/
let isCleanedUp = false;
/**
* If another instance want to run
@@ -127,6 +141,11 @@ app.whenReady().then(async () => {
closeSplashWindow();
getMainWindow().show();
if (configFeatures.systemTray) {
createSystemTray(getMainWindow(), config.devMode);
}
if (configFeatures.discordRPC) startRPC();
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow());
@@ -138,12 +157,33 @@ app.whenReady().then(async () => {
});
app.on("window-all-closed", () => {
if (app.isQuitting || !configFeatures.systemTray) {
logger.info("Shutting down...");
app.quit();
} else if (process.platform !== "darwin") {
logger.info("Main window closed, app running in background (Tray).");
}
});
app.on("before-quit", async () => {
app.on("before-quit", (event) => {
app.isQuitting = true;
if (!isCleanedUp) {
event.preventDefault();
(async () => {
try {
destroyTray();
await stopRPC();
} catch (err) {
logger.error("Error during cleanup:", err);
} finally {
logger.info("All services stopped. Have a nice day!");
logSessionEnd();
isCleanedUp = true;
app.quit();
}
})();
}
});

7216
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "freedom-loader",
"productName": "Freedom Loader",
"version": "1.6.0",
"version": "1.6.2-preview",
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
"homepage": "https://masteracnolo.github.io/Freedom-Loader-Site/",
@@ -15,13 +15,25 @@
},
"main": "main.js",
"scripts": {
"start": "electron --trace-warnings .",
"postinstall": "chmod +x resources/binaries/linux/* 2>/dev/null || true",
"start": "electron --no-warnings .",
"start:warn": "electron --trace-warnings .",
"start:debug": "WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .",
"dev": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --no-warnings .\"",
"dev:warn": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --trace-warnings .\"",
"dev:debug": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .\"",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:rpm": "bash scripts/create-copr-package.sh",
"build:all": "electron-builder -wl",
"release": "bash scripts/release.sh",
"release:publish": "bash scripts/release.sh --publish",
"release:dry-run": "bash scripts/release.sh --dry-run",
"test": "jest",
"test:unit": "jest test/unit",
"update": "npm update"
},
"dependencies": {
@@ -38,8 +50,13 @@
},
"devDependencies": {
"@electron/devtron": "^2.1.1",
"electron": "^41.2.0",
"electron-builder": "^25.1.8"
"@jest/globals": "^30.4.1",
"@playwright/test": "^1.62.1",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
"jest": "^30.4.2",
"nodemon": "^3.1.14",
"playwright": "^1.62.1"
},
"build": {
"appId": "com.masteracnolo.freedomloader",
@@ -67,6 +84,14 @@
"from": "theme",
"to": "theme"
},
{
"from": "build/app-icon.ico",
"to": "app-icon.ico"
},
{
"from": "build/app-icon-64x64.png",
"to": "app-icon-64x64.png"
},
{
"from": "build/confirm-icon.png",
"to": "confirm-icon.png"
@@ -91,7 +116,7 @@
"win": {
"target": "nsis",
"icon": "build/app-icon.ico",
"artifactName": "${productName}-Setup-${version}.${ext}",
"artifactName": "Freedom-Loader-Setup-${version}.${ext}",
"extraResources": [
{
"from": "resources/binaries/win-32/yt-dlp.exe",

View File

@@ -106,10 +106,10 @@
</div>
<div class="setting-item">
<input type="checkbox" data-key="autoCheckInfo" id="autoCheckInfo">
<input data-key="keepPlaylistOrder" id="keepPlaylistOrder" type="checkbox">
<div class="setting-info">
<span class="setting-title">Auto Fetch Info</span>
<small>Automatically fetch information for new downloads.</small>
<span class="setting-title">Keep Playlist Order</span>
<small>Add index before output file, to keep the order.</small>
</div>
</div>
</div>
@@ -127,6 +127,14 @@
</div>
</div>
<div class="setting-item">
<input data-key="systemTray" id="systemTray" type="checkbox">
<div class="setting-info">
<span class="setting-title">System Tray</span>
<small>Minimize app on close, in the System Tray.</small>
</div>
</div>
<!-- <div class="setting-item">
<input type="checkbox" data-key="outputTitleCheck" id="outputTitleCheck">
<div class="setting-info">
@@ -135,6 +143,14 @@
</div>
</div> -->
<div class="setting-item">
<input data-key="autoCheckInfo" id="autoCheckInfo" type="checkbox">
<div class="setting-info">
<span class="setting-title">Auto Fetch Info</span>
<small>Automatically fetch information for new downloads.</small>
</div>
</div>
<div class="setting-item">
<input type="checkbox" data-key="addMetadata" id="addMetadata">
<div class="setting-info">

View File

@@ -1,21 +1,54 @@
/**
* Retrieves the application version from the Electron main process
* and displays it in the UI version badge (bottom-right corner).
* Retrieves the application version and build state from the backend.
* Displays a formatted string indicating packaging state and preview status.
* Hides the "Packaged" state for production builds to keep the UI clean.
*
* This is mainly used for debugging and build identification.
* Example outputs: "Not Packaged Preview v1.6.2" or "Preview v1.6.2"
*
* @returns {Promise<void>}
*/
async function versionLabel() {
/**
* Raw version string from the backend (e.g., "dev-1.6.2-preview" or "v1.6.2")
* @type {string}
*/
const appVersion = await window.electronAPI.getVersion();
/**
* UI element displaying the current application version.
* Updated at runtime after IPC call resolves.
* @type {HTMLElement | null}
*/
const versionBadge = document.getElementById("version-badge");
if (!versionBadge) return;
versionBadge.textContent = `${appVersion}`;
/**
* Extracts the numeric semantic version (e.g., "1.6.2").
* @type {RegExpMatchArray | null}
*/
const versionMatch = appVersion.match(/(\d+\.\d+\.\d+)/);
const baseVersion = versionMatch ? versionMatch[0] : "Unknown";
/**
* Determines the packaging state. The backend prepends "dev-" if unpackaged.
* @type {boolean}
*/
const isPackaged = !appVersion.includes("dev-");
/**
* Determines if the current build is a preview version.
* @type {boolean}
*/
const isPreview = appVersion.includes("preview");
/**
* Construct the final display string.
* If packaged, we don't mention it. If not, we explicitly say "Not Packaged ".
*/
const packageStateStr = isPackaged ? "" : "Not Packaged ";
const previewStr = isPreview ? "Preview " : "";
versionBadge.textContent = `${packageStateStr}${previewStr}v${baseVersion}`;
}
versionLabel();

View File

@@ -30,15 +30,15 @@ function formatSize(bytes) {
*/
async function fetchVideoInfo(url) {
try {
const res = await fetch("/info", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ url }),
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
method: "GET",
headers: {"Accept": "application/json"}
});
if (!res.ok) return { error: `An Error occured when fetching info` };
const data = await res.json();
if (!res.ok) return {error: `An Error occured when fetching info`};
if (!data) return { error: "Data is Missing" };
return data;

View File

@@ -6,15 +6,74 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
RPM_OUT="$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm"
# ------------------------------------------------------------
# Resolve fpm from electron-builder cache
# Excludes Ruby gem wrappers, targets standalone binaries only
# Prefers fpm@2.x over legacy fpm-1.9.x
# ------------------------------------------------------------
FPM=$(find ~/.cache/electron-builder -name "fpm" -type f 2>/dev/null \
| grep -v "/gems/bin/fpm" \
| grep -v "/gems/gems/" \
| grep -v "/lib/app/bin/fpm" \
| grep "fpm@" \
| head -1)
# Fallback to legacy fpm-1.9.x if fpm@2.x not found
if [ -z "$FPM" ]; then
FPM=$(find ~/.cache/electron-builder -name "fpm" -type f 2>/dev/null \
| grep -v "/gems/" \
| grep -v "/lib/app/bin/fpm" \
| head -1)
fi
if [ -z "$FPM" ]; then
echo "Error: fpm not found in electron-builder cache. Run 'npm run build:linux' once first to download it."
exit 1
fi
echo "Using fpm: $FPM"
# Check that linux-unpacked exists
if [ ! -d "$ROOT_DIR/dist/linux-unpacked" ]; then
echo "Error: dist/linux-unpacked not found. Run 'npm run build:linux' first."
exit 1
fi
# Step 1 — Build .rpm from linux-unpacked via fpm
# ------------------------------------------------------------
# Prepare staging files
# fpm requires exact file-to-destination mappings to avoid
# scanning system directories (which causes permission errors)
# ------------------------------------------------------------
# Shell wrapper for /usr/bin/freedom-loader
TMP_WRAPPER="$ROOT_DIR/.rpm-pkg-tmp-wrapper"
mkdir -p "$TMP_WRAPPER"
cat > "$TMP_WRAPPER/freedom-loader" << 'EOF'
#!/bin/sh
exec /opt/freedom-loader/freedom-loader "$@"
EOF
chmod +x "$TMP_WRAPPER/freedom-loader"
# Desktop entry file
TMP_DESKTOP="$ROOT_DIR/.rpm-pkg-tmp-desktop"
mkdir -p "$TMP_DESKTOP"
cat > "$TMP_DESKTOP/freedom-loader.desktop" << 'EOF'
[Desktop Entry]
Name=Freedom Loader
Exec=/opt/freedom-loader/freedom-loader %U
Icon=freedom-loader
Type=Application
Categories=AudioVideo;Utility;Network;
Comment=Free and open-source GUI for yt-dlp
StartupWMClass=Freedom Loader
EOF
# ------------------------------------------------------------
# Step 1 - Build .rpm from linux-unpacked via fpm
# ------------------------------------------------------------
echo "Building RPM..."
fpm -s dir -t rpm --force \
"$FPM" -s dir -t rpm --force \
--rpm-use-file-permissions \
--name freedom-loader \
--version "$VERSION" \
--architecture x86_64 \
@@ -26,14 +85,21 @@ fpm -s dir -t rpm --force \
--depends nss \
--depends libXScrnSaver \
--depends at-spi2-core \
--rpm-attr "4755,root,root:/opt/freedom-loader/chrome-sandbox" \
--package "$RPM_OUT" \
"$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \
"$TMP_WRAPPER/freedom-loader=/usr/bin/freedom-loader" \
"$TMP_DESKTOP/freedom-loader.desktop=/usr/share/applications/freedom-loader.desktop" \
"$ROOT_DIR/build/app-icon.png=/usr/share/icons/hicolor/512x512/apps/freedom-loader.png" \
"$ROOT_DIR/package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
rm -rf "$TMP_WRAPPER" "$TMP_DESKTOP"
echo "RPM built: $RPM_OUT"
# Step 2 — Build SRPM for COPR (wraps the .rpm above)
# ------------------------------------------------------------
# Step 2 - Build SRPM for COPR
# Wraps the .rpm above into a source RPM for COPR submission
# ------------------------------------------------------------
echo "Building SRPM for COPR..."
mkdir -p "$ROOT_DIR/srpm-out"

187
scripts/release.sh Executable file
View File

@@ -0,0 +1,187 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
TAG="$VERSION"
# Parse arguments
PUBLISH_SNAP=false
PUBLISH_COPR=false
DRY_RUN=false
for arg in "$@"; do
case $arg in
--snap) PUBLISH_SNAP=true ;;
--copr) PUBLISH_COPR=true ;;
--publish) PUBLISH_SNAP=true; PUBLISH_COPR=true ;;
--dry-run) DRY_RUN=true ;;
*) echo "⚠️ Argument inconnu : $arg" ; exit 1 ;;
esac
done
echo ""
echo "============================================"
echo " Freedom Loader - Release Pipeline"
echo " Version : $VERSION"
echo " Tag : $TAG"
echo " Snap : $PUBLISH_SNAP"
echo " COPR : $PUBLISH_COPR"
echo " Dry run : $DRY_RUN"
echo "============================================"
echo ""
# ------------------------------------------------------------
# Step 0.1 - Check Dependencies
# ------------------------------------------------------------
echo "[0/5] Checking dependencies..."
command -v gh >/dev/null 2>&1 || { echo "'gh' CLI missing, need to be installed (https://cli.github.com/)."; exit 1; }
if [ "$PUBLISH_SNAP" = true ] && [ "$DRY_RUN" = false ]; then
command -v snapcraft >/dev/null 2>&1 || { echo "'snapcraft' missing, need to be installed."; exit 1; }
fi
if [ "$PUBLISH_COPR" = true ] && [ "$DRY_RUN" = false ]; then
command -v copr-cli >/dev/null 2>&1 || { echo "'copr-cli' missing, need to be installed."; exit 1; }
fi
echo "All dependencies found"
# ------------------------------------------------------------
# Step 0.2 - Clean Workspace
# ------------------------------------------------------------
echo "[0/5] Cleaning workspace..."
# On supprime les dossiers générés lors des builds précédents
rm -rf "$ROOT_DIR/dist" "$ROOT_DIR/srpm-out"
echo "Workspace cleaned (dist/ and srpm-out/ removed)"
# ------------------------------------------------------------
# Step 1 - Build Linux packages (AppImage, deb, snap)
# ------------------------------------------------------------
echo "[1/5] Building Linux packages..."
cd "$ROOT_DIR"
npm run build:linux
echo "Linux packages built"
# ------------------------------------------------------------
# Step 2 - Build RPM binary + SRPM for COPR
# Uses the create-copr-package.sh script which handles:
# - fpm binary resolution from electron-builder cache
# - .rpm generation from linux-unpacked
# - .src.rpm generation for COPR submission
# ------------------------------------------------------------
echo "[2/5] Building RPM and SRPM..."
bash "$ROOT_DIR/scripts/create-copr-package.sh"
echo "RPM and SRPM built"
# ------------------------------------------------------------
# Step 3 - Build Windows installer
# Note: requires Windows binaries to be present locally under
# resources/binaries/win-32/ (not committed to the repo)
# ------------------------------------------------------------
echo "[3/5] Building Windows installer..."
npm run build:win
echo " Windows installer built"
# ------------------------------------------------------------
# Step 4 - Create a draft release on GitHub
# Release notes are pre-filled with the standard template.
# Edit and publish manually from the GitHub web interface.
# Requires gh CLI to be installed and authenticated.
# ------------------------------------------------------------
echo "[4/5] Creating GitHub draft release $TAG..."
# Utilise une méthode un peu plus robuste pour trouver l'ancien tag (ignore le HEAD actuel s'il est déjà tagué)
PREV_TAG=$(git rev-list --tags --skip=1 --max-count=1 | xargs git describe --tags 2>/dev/null || echo "previous")
RELEASE_NOTES="# Freedom Loader - $VERSION
## Found a bug or issue?
Please report it in the [GitHub Issues](https://github.com/MasterAcnolo/Freedom-Loader/issues) section.
## Next Release (non-exhaustive roadmap)
- More format options
- Subtitle support
- Improved UI / UX
- Language selection
- Download specific parts of videos
- File renaming options
- Parallel downloads
- Skip sponsored parts automatically
**Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/${PREV_TAG}...${TAG}"
if [ "$DRY_RUN" = false ]; then
gh release create "$TAG" \
--title "$TAG" \
--notes "$RELEASE_NOTES" \
--draft \
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
"$ROOT_DIR/dist/Freedom Loader-${VERSION}.AppImage" \
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
"$ROOT_DIR/dist/latest.yml" \
"$ROOT_DIR/dist/latest-linux.yml"
echo "Draft release created: https://github.com/MasterAcnolo/Freedom-Loader/releases"
else
echo " [DRY RUN] gh release create $TAG (skipped)"
fi
# ------------------------------------------------------------
# Step 5 - Publish to package stores
# Snap and COPR are opt-in via --snap, --copr, or --publish.
# Without these flags, this step is skipped entirely.
# Run after the GitHub release is published (not the draft),
# so store users always get the same version as GitHub users.
# ------------------------------------------------------------
echo "[5/5] Publishing to package stores..."
if [ "$PUBLISH_SNAP" = true ]; then
echo " Publishing to Snap Store..."
if [ "$DRY_RUN" = false ]; then
export PATH=$PATH:/var/lib/snapd/snap/bin
snapcraft upload \
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
--release=stable
echo " Snap published"
else
echo " [DRY RUN] snapcraft upload (skipped)"
fi
else
echo " Snap: skipped (use --snap or --publish to enable)"
fi
if [ "$PUBLISH_COPR" = true ]; then
echo " Publishing to COPR..."
if [ "$DRY_RUN" = false ]; then
copr-cli build freedom-loader \
"$ROOT_DIR/srpm-out/freedom-loader-${VERSION}-1"*.src.rpm
echo "COPR build triggered"
else
echo " [DRY RUN] copr-cli build (skipped)"
fi
else
echo " COPR: skipped (use --copr or --publish to enable)"
fi
# ------------------------------------------------------------
# Done - Summary
# ------------------------------------------------------------
echo ""
echo "============================================"
echo " Release pipeline complete."
echo ""
echo " Next steps:"
echo " 1. Fill in the release notes on GitHub"
echo " 2. Publish the draft release"
if [ "$PUBLISH_SNAP" = false ] || [ "$PUBLISH_COPR" = false ]; then
echo ""
echo " Store publishing (not done yet):"
[ "$PUBLISH_SNAP" = false ] && echo " - Snap : bash scripts/release.sh --snap"
[ "$PUBLISH_COPR" = false ] && echo " - COPR : bash scripts/release.sh --copr"
fi
echo "============================================"

View File

@@ -18,7 +18,9 @@ const { isValidUrl } = require("../helpers/validation.helpers");
*/
async function infoController(req, res) {
const url = req.body.url || req.query.url; // Supports POST and GET requests
// 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:
@@ -30,9 +32,10 @@ async function infoController(req, res) {
return res.status(400).send("Invalid URL Or Missing");
}
logger.info(`Info request received. URL: ${url}`);
logger.info(`Info request received. RAW URL: ${url}`);
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
// Lightweight heuristic to detect playlist URLs
// Lightweight heuristic to detect playlist URLs. It works for Youtube.
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
logger.info(
@@ -86,6 +89,16 @@ async function infoController(req, res) {
*/
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(

View File

@@ -4,6 +4,7 @@ const getUserBrowser = require("./getBrowser.helpers.js");
const { ffmpegPath, denoPath} = require("./path.helpers.js");
const { configFeatures } = require("../../config.js");
const { logger } = require("../logger.js");
const config = require("../../config")
/**
* Validates whether the provided codec is supported by the application.
@@ -62,7 +63,7 @@ function validateCodec(codec){
*
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
*/
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
logger.info("--- CONFIGURATION ---");
@@ -70,6 +71,7 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`);
logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`);
@@ -117,7 +119,27 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
};
args.push("-f", qualityMap[quality] || "best");
args.push("-o", path.join(outputFolder, "%(title)s.%(ext)s"));
/**
* Default title output template
* @type {string}
*/
let outputTemplate = "%(title)s.%(ext)s";
// If it's a playlist, and we want to keep the playlist order. Add an index before the title.
if (isPlaylist && configFeatures.keepPlaylistOrder && configFeatures.autoDownloadPlaylist) {
outputTemplate = "%(playlist_index)02d - %(title)s.%(ext)s";
}
// Add a custom signature in each file downloaded with the app. It's a small things but i found it cool :)
if (configFeatures.addMetadata) {
args.push(
"--postprocessor-args",
`ffmpeg:-metadata comment="Downloaded with Freedom Loader v${config.version}"`
);
}
args.push("-o", path.join(outputFolder, outputTemplate));
args.push(url);
return args.filter(Boolean);

View File

@@ -2,6 +2,12 @@ const { Notification, shell } = require("electron");
const { iconPaths } = require("./path.helpers");
const { logger } = require("../logger");
/**
* Set that is holding all notifications. Used to avoid Race Condition and notification crash
* @type {Set<any>}
*/
const activeNotifications = new Set();
/**
* Displays a system notification when a download completes successfully.
*
@@ -21,7 +27,27 @@ function notifyDownloadFinished(folder, notifyEnabled = true) {
icon: iconPaths.confirm,
});
notif.on("click", () => shell.openPath(folder));
// Protect notification to garbage collection and race condition, by adding a 150ms timeout before trying to open the folder
activeNotifications.add(notif);
notif.on("click", () => {
activeNotifications.delete(notif);
setTimeout(() => {
shell.openPath(folder).then((errorMessage) => {
if (errorMessage) {
logger.error(`Impossible d'ouvrir le dossier : ${errorMessage}`);
}
}).catch(err => {
logger.error(`Erreur inattendue de shell.openPath : ${err}`);
});
}, 150);
});
notif.on("close", () => {
activeNotifications.delete(notif);
});
notif.show();
}
@@ -41,11 +67,16 @@ function notifyCookiesBrowserError(){
icon: iconPaths.error,
});
notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
activeNotifications.add(notif);
notif.on("click", () => {
shell.openExternal("https://www.firefox.com/en-US/download/");
activeNotifications.delete(notif);
});
notif.on("close", () => {
activeNotifications.delete(notif);
});
notif.show();
}
@@ -65,11 +96,16 @@ function notifyFirefoxBrowserMissing() {
icon: iconPaths.error,
});
notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
activeNotifications.add(notif);
notif.on("click", () => {
shell.openExternal("https://www.firefox.com/en-US/download/");
activeNotifications.delete(notif);
});
notif.on("close", () => {
activeNotifications.delete(notif);
});
notif.show();
}

View File

@@ -22,6 +22,31 @@ const logFormat = format.combine(
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
);
/**
* Format used by the saved file
* @type {Format}
*/
const fileFormat = format.combine(
format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}),
format.errors({stack: true}), // Capture la stack trace complète des erreurs
format.printf(({timestamp, level, message, stack}) => {
return `${timestamp} | ${level.toUpperCase()} | ${stack || message}`;
})
);
/**
* Formated used by the IDE/Console
* @type {Format}
*/
const consoleFormat = format.combine(
format.colorize({all: true}),
format.timestamp({format: "HH:mm:ss"}),
format.errors({stack: true}),
format.printf(({timestamp, level, message, stack}) => {
return `[${timestamp}] ${level}: ${stack || message}`;
})
);
/**
* Logger Instance with differents types
* - info
@@ -33,7 +58,6 @@ const logFormat = format.combine(
*/
const logger = createLogger({
level: "info",
format: logFormat,
transports: [
new DailyRotateFile({
dirname: logDir,
@@ -41,11 +65,11 @@ const logger = createLogger({
datePattern: "YYYY-MM-DD",
zippedArchive: false,
maxFiles: "7d",
format: logFormat,
format: fileFormat,
options: { flags: "a" },
}),
new transports.Console({
format: logFormat,
format: consoleFormat,
}),
],
});
@@ -54,16 +78,17 @@ const logger = createLogger({
* Start Log Session
*/
function logSessionStart(logDir, downloadPath) {
logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
logger.info(`--- Starting session: ${new Date().toLocaleString('sv-SE')} ---`);
logger.info("============================================================")
logger.info(`Application Version: ${config.version}`)
logSystemInfo(logger, logDir, downloadPath)
if (typeof logSystemInfo === 'function') logSystemInfo(logger, logDir, downloadPath);
}
/**
* Stop Log Session
*/
function logSessionEnd() {
logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
logger.info(`--- Ending session: ${new Date().toLocaleString('sv-SE')} ---`);
}
module.exports = {

View File

@@ -2,6 +2,6 @@ const express = require("express");
const router = express.Router();
const { infoController } = require("../controller/info.controller");
router.post("/", infoController);
router.get("/", infoController);
module.exports = router;

View File

@@ -14,6 +14,8 @@ app.use(express.static(path.join(__dirname, "../public")));
// Routes
app.use("/download", require("./routes/download.route"));
app.use("/info", require("./routes/info.route"));
// Interface
app.get("/", rateLimit, (req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html"));
});

View File

@@ -6,7 +6,7 @@ const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
const notify = require("../helpers/notify.helpers");
const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers");
const { configFeatures } = require("../../config.js");
const {reloadFeatures} = require("../../config");
/**
* Reference to the currently running yt-dlp process.
@@ -123,7 +123,7 @@ function createPlaylistFolder(basePath, playlistTitle) {
* @param {string} [options.outputFolder] - Destination folder.
* @param {string} [options.playlistTitle] - Playlist title.
*
* @param {Function[]} listeners - Progress listeners.
* @param {Function[]} progressListeners - Progress listeners.
* @param {Function[]} speedListeners - Download speed listeners.
* @param {Function[]} stageListeners - Processing stage listeners.
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
@@ -132,10 +132,13 @@ function createPlaylistFolder(basePath, playlistTitle) {
* Resolves with the final output folder path once the
* download has completed successfully.
*/
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
function fetchDownload(options, progressListeners, speedListeners, stageListeners, playlistInfoListeners) {
return new Promise((resolve, reject) => {
logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
const userConfig = reloadFeatures();
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
let outputFolder = options.outputFolder || defaultDownloadFolder;
@@ -159,7 +162,7 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
// Détecte si c'est une playlist et crée un dossier approprié
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
if (isPlaylist && configFeatures.createPlaylistFolders) {
if (isPlaylist && userConfig.createPlaylistFolders) {
try {
const playlistName = options.playlistTitle || "Untitled Playlist";
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
@@ -168,11 +171,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
logger.error(`Failed to create playlist folder: ${err.message}`);
return reject(err);
}
} else if (isPlaylist && !configFeatures.createPlaylistFolders) {
} else if (isPlaylist && !userConfig.createPlaylistFolders) {
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
}
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder });
const args = buildYtDlpArgs({
...options,
outputFolder: safeOutputFolder,
isPlaylist
});
logger.info(`[yt-dlp args] ${args.join(" ")}`);
const child = execFile(userYtDlp, args);
@@ -182,7 +190,7 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
child.on("close", code => {
currentDownloadProcess = null;
listeners.forEach(fn => fn("done"));
progressListeners.forEach(fn => fn("done"));
if (code === 0) resolve(safeOutputFolder);
else if (code === null) reject(new Error(`Download cancelled by user`));
else reject(new Error(`YT-DLP failed with code : ${code}`));
@@ -196,11 +204,11 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
// Progress Bar
if (line.startsWith("[download] Destination:")) {
listeners.forEach(fn => fn("reset"));
progressListeners.forEach(fn => fn("reset"));
stageListeners.forEach(fn => fn("Downloading..."));
}
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
if (match) listeners.forEach(fn => fn(parseFloat(match[1])));
if (match) progressListeners.forEach(fn => fn(parseFloat(match[1])));
if (line.includes("MiB/s") || line.includes("KiB/s")) {
const match = line.match(/\d+(\.\d+)?[KM]?iB\/s/);

182
test/unit/buildArgs.test.js Normal file
View File

@@ -0,0 +1,182 @@
const path = require("path");
jest.mock("../../server/helpers/getBrowser.helpers.js", () =>
jest.fn(() => "firefox")
);
jest.mock("../../server/helpers/path.helpers.js", () => ({
ffmpegPath: "/mock/ffmpeg",
denoPath: "/mock/deno",
}));
jest.mock("../../config.js", () => ({
configFeatures: {
autoUpdate: false,
discordRPC: false,
customTopBar: false,
autoCheckInfo: false,
addThumbnail: true,
addMetadata: true,
verboseLogs: false,
autoDownloadPlaylist: false,
customCodec: "h264",
},
}));
jest.mock("../../server/logger.js", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
const { buildYtDlpArgs } = require("../../server/helpers/buildArgs.helpers");
const { configFeatures } = require("../../config");
const { logger } = require("../../server/logger");
describe("buildYtDlpArgs", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("builds default video arguments", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(args).toContain("--cookies-from-browser");
expect(args).toContain("firefox");
expect(args).toContain("--ffmpeg-location");
expect(args).toContain("/mock/ffmpeg");
expect(args).toContain("--js-runtimes");
expect(args).toContain("deno:/mock/deno");
expect(args).toContain("--merge-output");
expect(args).toContain("mp4");
expect(args).toContain("-f");
expect(args).toContain("bestvideo+bestaudio/best/mp4");
expect(args).toContain(
path.join("/downloads", "%(title)s.%(ext)s")
);
expect(args.at(-1)).toBe("https://youtube.com/watch?v=123");
});
test("builds audio-only arguments", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: true,
quality: "best",
outputFolder: "/downloads",
});
expect(args).toContain("--extract-audio");
expect(args).toContain("--audio-format");
expect(args).toContain("mp3");
expect(args).toContain("bestaudio");
expect(args).not.toContain("--merge-output");
});
test("uses requested quality", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "720",
outputFolder: "/downloads",
});
expect(args).toContain(
"bestvideo[height<=720]+bestaudio/best[height<=720]/mp4"
);
});
test("falls back to best for unknown quality", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "unknown",
outputFolder: "/downloads",
});
expect(args).toContain("best");
});
test("uses --no-playlist by default", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(args).toContain("--no-playlist");
expect(args).not.toContain("--yes-playlist");
});
test("uses --yes-playlist when enabled", () => {
configFeatures.autoDownloadPlaylist = true;
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(args).toContain("--yes-playlist");
expect(args).not.toContain("--no-playlist");
configFeatures.autoDownloadPlaylist = false;
});
test("embeds thumbnail and metadata when enabled", () => {
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(args).toContain("--embed-thumbnail");
expect(args).toContain("--add-metadata");
});
test("logs codec validation", () => {
buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(logger.info).toHaveBeenCalledWith("Codec Valid: h264");
});
test("falls back to h264 when codec is invalid", () => {
configFeatures.customCodec = "invalid";
const args = buildYtDlpArgs({
url: "https://youtube.com/watch?v=123",
audioOnly: false,
quality: "best",
outputFolder: "/downloads",
});
expect(logger.error).toHaveBeenCalledWith(
"Codec not valid: invalid. Using default codec"
);
const sortIndex = args.indexOf("-S");
expect(args[sortIndex + 1]).toContain("vcodec:h264");
configFeatures.customCodec = "h264";
});
});

View File

@@ -0,0 +1,133 @@
const mockShow = jest.fn();
const mockOn = jest.fn();
const mockOpenPath = jest.fn();
const mockOpenExternal = jest.fn();
jest.mock("electron", () => ({
Notification: jest.fn().mockImplementation(() => ({
show: mockShow,
on: mockOn,
})),
shell: {
openPath: mockOpenPath,
openExternal: mockOpenExternal,
},
}));
jest.mock("../../server/helpers/path.helpers", () => ({
iconPaths: {
confirm: "/icons/confirm.png",
error: "/icons/error.png",
},
}));
jest.mock("../../server/logger", () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
const { Notification, shell } = require("electron");
const {
notifyDownloadFinished,
notifyCookiesBrowserError,
notifyFirefoxBrowserMissing,
} = require("../../server/helpers/notify.helpers");
describe("notify.helpers", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("notifyDownloadFinished", () => {
test("does nothing when notifications are disabled", () => {
notifyDownloadFinished("/downloads", false);
expect(Notification).not.toHaveBeenCalled();
});
test("does nothing when folder is missing", () => {
notifyDownloadFinished(undefined, true);
expect(Notification).not.toHaveBeenCalled();
});
test("creates and shows notification", () => {
notifyDownloadFinished("/downloads");
expect(Notification).toHaveBeenCalledWith({
title: "Freedom Loader",
body: "Your download is complete, click here to open it.",
icon: "/icons/confirm.png",
});
expect(mockOn).toHaveBeenCalledWith(
"click",
expect.any(Function)
);
expect(mockShow).toHaveBeenCalled();
});
test("opens folder when notification is clicked", () => {
notifyDownloadFinished("/downloads");
const callback = mockOn.mock.calls[0][1];
callback();
expect(shell.openPath).toHaveBeenCalledWith("/downloads");
});
});
describe("notifyCookiesBrowserError", () => {
test("creates notification", () => {
notifyCookiesBrowserError();
expect(Notification).toHaveBeenCalledWith({
title: "Cookies Error",
body: "Unable to retrieve cookies. Please log in to your browser and click here to view the tutorial.",
icon: "/icons/error.png",
});
expect(mockShow).toHaveBeenCalled();
});
test("opens tutorial when clicked", () => {
notifyCookiesBrowserError();
const callback = mockOn.mock.calls[0][1];
callback();
expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
);
});
});
describe("notifyFirefoxBrowserMissing", () => {
test("creates notification", () => {
notifyFirefoxBrowserMissing();
expect(Notification).toHaveBeenCalledWith({
title: "Firefox Missing",
body: "Firefox was not found on your system. Click here to follow the installation guide",
icon: "/icons/error.png",
});
expect(mockShow).toHaveBeenCalled();
});
test("opens installation guide when clicked", () => {
notifyFirefoxBrowserMissing();
const callback = mockOn.mock.calls[0][1];
callback();
expect(shell.openExternal).toHaveBeenCalledWith(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
);
});
});
});