36 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
MasterAcnolo
f0971ebccc fix: adjust size for custom controls in topbar 2026-10-06 21:43:27 +02:00
MasterAcnolo
18c8621095 fix: update release script to reference new RELEASE-CHANGELOG.md and correct deb package name 2026-10-06 21:43:11 +02:00
MasterAcnolo
b2eb3b3b40 feat: implement changelog feature with UI modal and caching 2026-10-06 21:42:57 +02:00
MasterAcnolo
7af6898301 fix: moved CHANGELOD.md to root 2026-10-05 10:54:59 +02:00
MasterAcnolo
dc8d0a62d7 fix: enhance clean:dist and build:rpm npm scripts 2026-10-04 23:09:15 +02:00
MasterAcnolo
cf65069496 fix: extract .desktop inside a dedicated file and add npm run test:metainfo and test:desktop to check file.
also clean:dist
2026-10-02 14:52:30 +02:00
MasterAcnolo
a308352ea0 fix: update website links to new domain 2026-10-02 09:26:46 +02:00
MasterAcnolo
3712813833 fix: update desktop entry and metainfo filename 2026-10-02 09:15:19 +02:00
48 changed files with 2099 additions and 364 deletions

View File

@@ -9,8 +9,8 @@ assignees: 'MasterAcnolo'
## Pre-submission Checklist ## Pre-submission Checklist
- [ ] I have checked the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/#/faq) - [ ] I have checked the [FAQ](https://freedomloader.acnolo.fr/#/faq)
- [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/#/wiki) - [ ] I have reviewed the [Wiki](https://freedomloader.acnolo.fr/#/wiki)
- [ ] I have searched existing issues - [ ] I have searched existing issues
## Question ## Question

View File

@@ -112,8 +112,8 @@ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details.
## Questions? ## Questions?
- Check the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/faq) - Check the [FAQ](https://freedomloader.acnolo.fr/faq)
- Review the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) - Review the [Wiki](https://freedomloader.acnolo.fr/wiki)
- Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose) - Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose)
--- ---

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<a href="https://masteracnolo.github.io/Freedom-Loader-Site/"><img <a href="https://freedomloader.acnolo.fr/"><img
src="./build/banner.png" src="./build/banner.png"
alt="Banner" alt="Banner"
style="width: 50%;"/></a> style="width: 50%;"/></a>
@@ -9,7 +9,7 @@
[![Release](https://img.shields.io/github/v/release/MasterAcnolo/Freedom-Loader?style=for-the-badge&color=blue)](https://github.com/MasterAcnolo/Freedom-Loader/releases) [![Release](https://img.shields.io/github/v/release/MasterAcnolo/Freedom-Loader?style=for-the-badge&color=blue)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0)
[![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/) [![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://freedomloader.acnolo.fr/)
[![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/) [![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
<a href="https://www.firefox.com/fr/?utm_campaign=SET_DEFAULT_BROWSER"><img src="https://img.shields.io/badge/Require Firefox-E66000?style=for-the-badge&logo=Firefox-Browser&logoColor=white"></a> <a href="https://www.firefox.com/fr/?utm_campaign=SET_DEFAULT_BROWSER"><img src="https://img.shields.io/badge/Require Firefox-E66000?style=for-the-badge&logo=Firefox-Browser&logoColor=white"></a>
@@ -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
@@ -354,7 +358,7 @@ See [BINARIES.md](./BINARIES.md) for how to setup the binaries.
## Roadmap ## Roadmap
See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap). See the most recent Roadmap [Here](https://freedomloader.acnolo.fr/Roadmap).
## Contributing ## Contributing
@@ -434,6 +438,6 @@ You are free to use, modify, and redistribute this software under the terms of t
**Freedom Loader** - Put freedom in your downloads **Freedom Loader** - Put freedom in your downloads
[Website](https://masteracnolo.github.io/Freedom-Loader-Site/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues) [Website](https://freedomloader.acnolo.fr/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://freedomloader.acnolo.fr/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues)
</div> </div>

177
app/changelogManager.js Normal file
View File

@@ -0,0 +1,177 @@
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
const https = require("https");
const config = require("../config");
const { logger } = require("../server/logger");
/** Maximum age of the cached release list before it must be refreshed. */
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** Filename used to persist changelog data in the Electron user data folder. */
const CACHE_FILE_NAME = "changelog-cache.json";
/** GitHub API endpoint containing the application's published releases. */
const RELEASES_URL = "https://api.github.com/repos/MasterAcnolo/Freedom-Loader/releases";
/** Returns the path of the local changelog cache file. */
function getCachePath() {
return path.join(app.getPath("userData"), CACHE_FILE_NAME);
}
/**
* Extracts the numeric major, minor, and patch parts from a version string.
*
* @param {string} version - Version string or release tag
* @returns {number[]|null} Parsed version parts, or null when invalid
*/
function normalizeVersion(version) {
const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
return match ? match.slice(1).map(Number) : null;
}
/**
* Compares two semantic version strings.
*
* @param {string} left - First version to compare
* @param {string} right - Second version to compare
* @returns {number} Negative, zero, or positive comparison result
*/
function compareVersions(left, right) {
const leftParts = normalizeVersion(left) || [0, 0, 0];
const rightParts = normalizeVersion(right) || [0, 0, 0];
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
/** Reads and parses the cached changelog, if one is available. */
function readCache() {
try {
return JSON.parse(fs.readFileSync(getCachePath(), "utf8"));
} catch {
return null;
}
}
/** Persists the fetched releases together with their cache timestamp. */
function writeCache(releases) {
try {
fs.writeFileSync(
getCachePath(),
JSON.stringify({ cachedAt: Date.now(), releases }, null, 2),
"utf8",
);
} catch (error) {
logger.warn(`Unable to cache changelog: ${error.message}`);
}
}
/**
* Fetches published releases from GitHub and maps them to the UI data shape.
*
* @returns {Promise<object[]>} Published releases
*/
function fetchReleases() {
return new Promise((resolve, reject) => {
const request = https.get(
RELEASES_URL,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "Freedom-Loader",
},
},
(response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
body += chunk;
});
response.on("end", () => {
// Do not attempt to parse an error response as a release list.
if (response.statusCode !== 200) {
reject(new Error(`GitHub returned HTTP ${response.statusCode}`));
return;
}
try {
// Draft releases are not visible to users and should not appear in the changelog.
const releases = JSON.parse(body)
.filter((release) => !release.draft)
.map((release) => ({
version: release.tag_name,
name: release.name || release.tag_name,
date: release.published_at || release.created_at,
body: release.body || "No changelog available.",
url: release.html_url,
}));
resolve(releases);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () => {
request.destroy(new Error("Changelog request timed out"));
});
request.on("error", reject);
});
}
/**
* Returns cached or freshly fetched releases and identifies unread versions.
*
* @returns {Promise<object>} Changelog data for the renderer
*/
async function getChangelog() {
const cache = readCache();
let releases = cache?.releases || [];
const cacheIsFresh = cache && cache.releases?.length > 0 && Date.now() - cache.cachedAt < CACHE_TTL_MS;
// Refresh stale or missing data, while keeping the last cache as a fallback.
if (!cacheIsFresh) {
try {
releases = await fetchReleases();
writeCache(releases);
} catch (error) {
logger.warn(`Unable to retrieve changelog: ${error.message}`);
}
}
const lastSeenVersion = cache?.lastSeenVersion || null;
// Without a previous version, all available releases are considered unread.
const unreadReleases = lastSeenVersion
? releases.filter((release) => compareVersions(release.version, lastSeenVersion) > 0)
: releases;
return {
// An empty release list means that the fetch failed or returned no releases.
error: releases.length === 0,
currentVersion: config.version,
lastSeenVersion,
hasUnread: unreadReleases.length > 0,
releases,
unreadReleases,
};
}
/** Marks the current application version as seen in the local cache. */
function markChangelogSeen() {
const cache = readCache() || { releases: [] };
cache.lastSeenVersion = config.version;
cache.cachedAt = cache.cachedAt || 0 ;
fs.mkdirSync(path.dirname(getCachePath()), { recursive: true });
fs.writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), "utf8");
return true;
}
module.exports = { getChangelog, markChangelogSeen };

View File

@@ -95,7 +95,7 @@ function startRPC() {
smallImageText: "By MasterAcnolo", smallImageText: "By MasterAcnolo",
startTimestamp: new Date(), startTimestamp: new Date(),
details: `Open Source Download Tools - ${config.version}`, details: `Open Source Download Tools - ${config.version}`,
state: "masteracnolo.github.io/FreedomLoader", state: "freedomloader.acnolo.fr",
}; };
rpc.clearActivity(); rpc.clearActivity();
rpc.setActivity(presence); rpc.setActivity(presence);

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

@@ -21,6 +21,7 @@ const { userThemesPath } = require("../server/helpers/path.helpers");
const { createSystemTray, destroyTray } = require("./tray"); const { createSystemTray, destroyTray } = require("./tray");
const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport"); const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport");
const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater"); const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater");
const { getChangelog, markChangelogSeen } = require("./changelogManager");
/** /**
* Security whitelist for feature flags that can be modified at runtime. * Security whitelist for feature flags that can be modified at runtime.
@@ -29,21 +30,21 @@ const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpda
* 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"
]); ]);
/** /**
@@ -71,6 +72,16 @@ function registerIpcHandlers(getMainWindow) {
*/ */
ipcMain.handle("version", () => config.version); ipcMain.handle("version", () => config.version);
/**
* Retrieves the changelog from cache or GitHub API.
*/
ipcMain.handle("get-changelog", () => getChangelog());
/**
* Marks all changelog entries as seen and updates cache.
*/
ipcMain.handle("mark-changelog-seen", () => markChangelogSeen());
/** /**
* Returns runtime feature configuration object. * Returns runtime feature configuration object.
*/ */
@@ -157,14 +168,14 @@ function registerIpcHandlers(getMainWindow) {
* Opens external website in default browser. * Opens external website in default browser.
*/ */
ipcMain.on("open-website", () => ipcMain.on("open-website", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/") shell.openExternal("https://freedomloader.acnolo.fr/")
); );
/** /**
* Opens official wiki page in external browser. * Opens official wiki page in external browser.
*/ */
ipcMain.on("open-wiki", () => ipcMain.on("open-wiki", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki") shell.openExternal("https://freedomloader.acnolo.fr/wiki")
); );
/** /**

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"
} }

10
main.js
View File

@@ -13,6 +13,9 @@ process.on("unhandledRejection", (reason) => {
app.quit(); app.quit();
}); });
if (process.platform === 'linux') {
app.setDesktopName('com.masteracnolo.freedomloader.desktop');
}
/** /**
* True if this is the primary instance (lock acquired successfully) * True if this is the primary instance (lock acquired successfully)
@@ -142,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
@@ -170,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,10 +2,10 @@
"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://masteracnolo.github.io/Freedom-Loader-Site/", "homepage": "https://freedomloader.acnolo.fr/",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/MasterAcnolo/Freedom-Loader.git" "url": "https://github.com/MasterAcnolo/Freedom-Loader.git"
@@ -16,6 +16,7 @@
}, },
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"clean:dist": "rm -rf dist/ && rm -rf srpm-out/",
"start": "electron --no-warnings .", "start": "electron --no-warnings .",
"start:warn": "electron --trace-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 .", "start:debug": "WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .",
@@ -25,13 +26,15 @@
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win", "build:win": "electron-builder --win",
"build:linux": "electron-builder --linux", "build:linux": "electron-builder --linux",
"build:rpm": "bash scripts/create-copr-package.sh", "build:rpm": "npm run clean:dist && npm run build:linux && bash scripts/create-copr-package.sh",
"build:all": "electron-builder -wl", "build:all": "electron-builder -wl",
"release": "bash scripts/release.sh", "release": "bash scripts/release.sh",
"release:publish": "bash scripts/release.sh --publish", "release:publish": "bash scripts/release.sh --publish",
"release:dry-run": "bash scripts/release.sh --dry-run", "release:dry-run": "bash scripts/release.sh --dry-run",
"test": "jest", "test": "jest",
"test:unit": "jest test/unit", "test:unit": "jest test/unit",
"test:metainfo": "appstreamcli validate package/com.masteracnolo.freedomloader.metainfo.xml",
"test:desktop": "desktop-file-validate package/com.masteracnolo.freedomloader.desktop",
"lint": "eslint . --format pretty", "lint": "eslint . --format pretty",
"lint:fix": "eslint . --fix --format pretty", "lint:fix": "eslint . --fix --format pretty",
"update": "npm update" "update": "npm update"
@@ -82,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"
@@ -172,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

@@ -0,0 +1,8 @@
[Desktop Entry]
Name=Freedom Loader
Exec=/opt/freedom-loader/freedom-loader %U
Icon=freedom-loader
Type=Application
Categories=AudioVideo;
Comment=Free and open-source GUI for yt-dlp
StartupWMClass=com.masteracnolo.freedomloader

View File

@@ -45,7 +45,7 @@
</p> </p>
</description> </description>
<launchable type="desktop-id">freedom-loader.desktop</launchable> <launchable type="desktop-id">com.masteracnolo.freedomloader.desktop</launchable>
<url type="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url> <url type="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url>
<url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url> <url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url>

View File

@@ -69,6 +69,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
*/ */
getVersion: () => ipcRenderer.invoke("version"), getVersion: () => ipcRenderer.invoke("version"),
/**
* Returns the changelog data
*/
getChangelog: () => ipcRenderer.invoke("get-changelog"),
/**
* Marks the current changelog version as seen by the user.
*/
markChangelogSeen: () => ipcRenderer.invoke("mark-changelog-seen"),
/** /**
* Validates a download path before using it for file operations. * Validates a download path before using it for file operations.
* *
@@ -149,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

@@ -27,6 +27,7 @@
<button id="website-btn" title="Website">Website</button> <button id="website-btn" title="Website">Website</button>
<button id="wiki-btn" title="Wiki">Wiki</button> <button id="wiki-btn" title="Wiki">Wiki</button>
<button id="workshop-btn" title="Workshop">Theme Workshop</button> <button id="workshop-btn" title="Workshop">Theme Workshop</button>
<button id="changelog-btn" title="Changelog">Changelog</button>
<!-- <button id="theme-btn" title="Theme">Theme</button> --> <!-- <button id="theme-btn" title="Theme">Theme</button> -->
</div> </div>
<div class="window-controls"> <div class="window-controls">
@@ -123,38 +124,38 @@
<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>
<div class="setting-item"> <div class="setting-item">
<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>
<!-- <div class="setting-item"> <!-- <div class="setting-item">
<input type="checkbox" data-key="outputTitleCheck" id="outputTitleCheck"> <input type="checkbox" data-key="outputTitleCheck" id="outputTitleCheck">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Output title (special chars)</span> <span class="setting-title">Output title (special chars)</span>
<small>Check for unusual characters in output titles.</small> <small>Check for unusual characters in output titles.</small>
</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>
</div> -->
<div class="setting-item"> <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"> <input type="checkbox" data-key="addMetadata" id="addMetadata">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Add Metadata</span> <span class="setting-title">Add Metadata</span>
@@ -170,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">
@@ -216,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">
@@ -303,6 +312,21 @@
<!-- Toast Container --> <!-- Toast Container -->
<div id="toast-container"></div> <div id="toast-container"></div>
<div class="changelog-overlay" id="changelog-overlay" hidden>
<section class="changelog-modal" role="dialog" aria-modal="true" aria-labelledby="changelog-title">
<div class="changelog-header">
<div>
<span class="changelog-eyebrow">Freedom Loader</span>
<h2 id="changelog-title">What's new</h2>
</div>
<button class="changelog-close" id="changelog-close" type="button" title="Close changelog" aria-label="Close changelog">&times;</button>
</div>
<div class="changelog-list" id="changelog-list">
<p class="changelog-loading">Loading changelog...</p>
</div>
</section>
</div>
<h2 id="reference"> Because Why Not ? </h2> <h2 id="reference"> Because Why Not ? </h2>
<div id="updateAvailable" style="display:none"> <div id="updateAvailable" style="display:none">
@@ -343,6 +367,7 @@
<script src="script/customthemes.js"></script> <script src="script/customthemes.js"></script>
<script src="script/reportBug.js"></script> <script src="script/reportBug.js"></script>
<script src="script/autoUpdate.js"></script> <script src="script/autoUpdate.js"></script>
<script type="module" src="script/changelog.js"></script>
<script type="module" src="script/topbar.js"></script> <script type="module" src="script/topbar.js"></script>
</body> </body>

282
public/script/changelog.js Executable file
View File

@@ -0,0 +1,282 @@
/** DOM elements used by the changelog modal. */
const UI = {
overlay: document.getElementById("changelog-overlay"),
list: document.getElementById("changelog-list"),
closeBtn: document.getElementById("changelog-close"),
openBtn: document.getElementById("changelog-btn")
};
/** Changelog payload loaded from the Electron main process. */
let changelogData = null;
/**
* Formats an ISO release date for the current locale.
*
* @param {string} dateString - Release date returned by GitHub
* @returns {string} Localized date or an empty string
*/
function formatDate(dateString) {
if (!dateString) return "";
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(dateString));
}
/**
* Parses inline Markdown and appends safe DOM nodes to a parent element.
*
* @param {HTMLElement} parent - Element receiving the parsed content
* @param {string} value - Inline Markdown text
*/
function parseInline(parent, value) {
const tokenPattern = /(\*\*|__)(.+?)\1|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)|`([^`]+)`|~~([^~]+)~~|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = tokenPattern.exec(value)) !== null) {
if (match.index > lastIndex) {
parent.appendChild(document.createTextNode(value.slice(lastIndex, match.index)));
}
const element = match[7]
? document.createElement("a")
: document.createElement(match[1] ? "strong" : match[3] || match[4] ? "em" : match[5] ? "code" : "del");
element.textContent = match[7] || match[2] || match[3] || match[4] || match[5] || match[6];
if (match[7]) {
element.href = match[8];
element.target = "_blank";
element.rel = "noopener noreferrer";
}
parent.appendChild(element);
lastIndex = tokenPattern.lastIndex;
}
if (lastIndex < value.length) {
parent.appendChild(document.createTextNode(value.slice(lastIndex)));
}
}
/**
* Parses block-level Markdown such as headings, lists, quotes and code blocks.
*
* @param {HTMLElement} parent - Element receiving the parsed content
* @param {string} markdown - Release body in Markdown format
*/
function parseBlock(parent, markdown) {
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
let paragraph = [];
let list = null;
let codeBlock = null;
const flushParagraph = () => {
if (!paragraph.length) return;
const element = document.createElement("p");
paragraph.forEach((line, index) => {
if (index) element.appendChild(document.createElement("br"));
parseInline(element, line);
});
parent.appendChild(element);
paragraph = [];
};
const closeList = () => {
if (list) {
parent.appendChild(list);
list = null;
}
};
lines.forEach((line) => {
if (line.trim().startsWith("```")) {
flushParagraph();
closeList();
if (codeBlock) {
const code = document.createElement("code");
code.textContent = codeBlock.join("\n");
const pre = document.createElement("pre");
pre.appendChild(code);
parent.appendChild(pre);
codeBlock = null;
} else {
codeBlock = [];
}
return;
}
if (codeBlock) {
codeBlock.push(line);
return;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
const unorderedItem = line.match(/^\s*[-*+]\s+(.+)$/);
const orderedItem = line.match(/^\s*\d+[.]\s+(.+)$/);
const quote = line.match(/^\s*>\s?(.*)$/);
if (!line.trim()) {
flushParagraph();
closeList();
} else if (heading) {
flushParagraph();
closeList();
const element = document.createElement(`h${heading[1].length}`);
parseInline(element, heading[2]);
parent.appendChild(element);
} else if (unorderedItem || orderedItem) {
flushParagraph();
const isOrdered = Boolean(orderedItem);
if (!list || list.tagName.toLowerCase() !== (isOrdered ? "ol" : "ul")) {
closeList();
list = document.createElement(isOrdered ? "ol" : "ul");
}
const item = document.createElement("li");
parseInline(item, (unorderedItem || orderedItem)[1]);
list.appendChild(item);
} else if (quote) {
flushParagraph();
closeList();
const element = document.createElement("blockquote");
parseInline(element, quote[1]);
parent.appendChild(element);
} else if (/^\s*([-*_])\s*\1\s*\1\s*$/.test(line)) {
flushParagraph();
closeList();
parent.appendChild(document.createElement("hr"));
} else {
paragraph.push(line);
}
});
flushParagraph();
closeList();
if (codeBlock) {
const code = document.createElement("code");
code.textContent = codeBlock.join("\n");
const pre = document.createElement("pre");
pre.appendChild(code);
parent.appendChild(pre);
}
}
/** Renders the empty state when no releases are available. */
function renderEmpty() {
const emptyMessage = document.createElement("p");
emptyMessage.className = "changelog-empty";
emptyMessage.textContent = "No changelog available.";
UI.list.appendChild(emptyMessage);
}
/**
* Renders one release entry in the changelog list.
*
* @param {object} release - Release data from the backend
*/
function renderEntry(release) {
const article = document.createElement("article");
article.className = "changelog-entry";
const heading = document.createElement("div");
heading.className = "changelog-entry-heading";
const title = document.createElement("h3");
title.textContent = release.name;
const metadata = document.createElement("span");
metadata.textContent = [release.version, formatDate(release.date)]
.filter(Boolean)
.join(" · ");
heading.append(title, metadata);
const body = document.createElement("div");
body.className = "changelog-body";
parseBlock(body, release.body);
article.append(heading, body);
UI.list.appendChild(article);
}
/** Renders all releases, replacing the current modal contents. */
function renderReleases(releases) {
UI.list.replaceChildren();
if (!releases.length) {
renderEmpty();
return;
}
releases.forEach((release) => renderEntry(release));
}
/** Opens the changelog modal and moves focus to its close button. */
function openChangelog(releases) {
renderReleases(releases);
UI.overlay.hidden = false;
UI.closeBtn?.focus();
}
/** Closes the modal and records the current version as seen. */
async function closeChangelog() {
UI.overlay.hidden = true;
await window.electronAPI.markChangelogSeen();
}
/** Loads changelog data and opens the modal when unread releases exist. */
async function loadChangelog() {
const features = await window.electronAPI.getFeatures();
try {
changelogData = await window.electronAPI.getChangelog();
if (!changelogData.error && changelogData.hasUnread && features.showChanglogsAfterUpdate === true) {
openChangelog(changelogData.unreadReleases);
window.electronAPI.logInfo("Showing Changelog");
}
} catch (error) {
console.error("Unable to load changelog:", error);
}
}
/** Handles a click on the button that opens the changelog. */
async function handleOpenClick() {
if (!changelogData || changelogData.error) {
changelogData = await window.electronAPI.getChangelog();
}
if (changelogData.error) {
window.showError("Unable to load changelog. Please check your network connection.");
return;
}
openChangelog(changelogData.releases);
}
/** Closes the modal when the user clicks on the overlay background. */
function handleOverlayClick(event) {
if (event.target === UI.overlay) closeChangelog();
}
/** Closes the modal when Escape is pressed. */
function handleKeydown(event) {
if (event.key === "Escape" && !UI.overlay.hidden) closeChangelog();
}
/** Registers all changelog modal event listeners. */
function bindEvents() {
UI.openBtn?.addEventListener("click", handleOpenClick);
UI.closeBtn?.addEventListener("click", closeChangelog);
UI.overlay?.addEventListener("click", handleOverlayClick);
document.addEventListener("keydown", handleKeydown);
}
/** Initializes the changelog data flow and modal interactions. */
function init() {
bindEvents();
loadChangelog();
}
init();

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,8 +86,12 @@ form.addEventListener("submit", async (e) => {
} }
// Download successful - check if notifications are enabled // Download successful - check if notifications are enabled
const features = await window.electronAPI.getFeatures(); try {
if (features.notifySystem) { const features = await window.electronAPI.getFeatures();
if (features.notifySystem) {
window.showSuccess("Download completed!");
}
} catch (e) {
window.showSuccess("Download completed!"); window.showSuccess("Download completed!");
} }
@@ -80,10 +99,14 @@ form.addEventListener("submit", async (e) => {
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

@@ -0,0 +1,229 @@
.changelog-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(5, 10, 16, 0.72);
backdrop-filter: blur(8px);
}
.changelog-overlay[hidden] {
display: none;
}
.changelog-modal {
display: flex;
flex-direction: column;
width: min(680px, 100%);
max-height: min(720px, 88vh);
overflow: hidden;
color: var(--settings-text-color, #f8fafc);
background: var(--settings-modal-bg-color, #18212b);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
}
.changelog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 24px 28px 18px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.changelog-eyebrow {
color: var(--fill-accent, #0ea5e9);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.changelog-header h2 {
margin: 5px 0 0;
font-size: 26px;
}
.changelog-close {
width: 34px;
height: 34px;
padding: 0;
color: inherit;
font-size: 24px;
line-height: 1;
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
cursor: pointer;
}
.changelog-close:hover {
background: rgba(148, 163, 184, 0.14);
}
.changelog-list {
overflow-y: auto;
padding: 20px 28px 28px;
scrollbar-color: var(--fill-accent, #0ea5e9) transparent;
scrollbar-width: thin;
}
.changelog-list::-webkit-scrollbar,
.changelog-body pre::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.changelog-list::-webkit-scrollbar-track,
.changelog-body pre::-webkit-scrollbar-track {
background: transparent;
}
.changelog-list::-webkit-scrollbar-thumb,
.changelog-body pre::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fill-accent, #0ea5e9) 70%, transparent);
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
.changelog-list::-webkit-scrollbar-thumb:hover,
.changelog-body pre::-webkit-scrollbar-thumb:hover {
background: var(--fill-accent, #0ea5e9);
background-clip: padding-box;
}
.changelog-entry {
padding: 18px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
}
.changelog-entry:first-child {
padding-top: 0;
}
.changelog-entry:last-child {
border-bottom: 0;
}
.changelog-entry-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.changelog-entry-heading h3 {
margin: 0;
font-size: 17px;
}
.changelog-entry-heading span {
flex-shrink: 0;
color: var(--settings-subtitle-color, #94a3b8);
font-size: 12px;
}
.changelog-body {
margin: 12px 0 0;
color: var(--settings-subtitle-color, #cbd5e1);
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.changelog-body p {
margin: 0 0 10px;
}
.changelog-body p:last-child {
margin-bottom: 0;
}
.changelog-body h1,
.changelog-body h2,
.changelog-body h3,
.changelog-body h4,
.changelog-body h5,
.changelog-body h6 {
margin: 14px 0 8px;
color: var(--settings-text-color, #f8fafc);
}
.changelog-body h1 { font-size: 20px; }
.changelog-body h2 { font-size: 18px; }
.changelog-body h3 { font-size: 16px; }
.changelog-body h4,
.changelog-body h5,
.changelog-body h6 { font-size: 14px; }
.changelog-body ul,
.changelog-body ol {
margin: 8px 0 12px;
padding-left: 22px;
}
.changelog-body li + li {
margin-top: 4px;
}
.changelog-body code {
padding: 2px 5px;
color: var(--settings-text-color, #f8fafc);
background: rgba(148, 163, 184, 0.16);
border-radius: 4px;
font-size: 12px;
}
.changelog-body pre {
padding: 10px 12px;
overflow-x: auto;
background: rgba(15, 23, 42, 0.45);
border-radius: 6px;
}
.changelog-body pre code {
padding: 0;
background: transparent;
white-space: pre;
}
.changelog-body blockquote {
margin: 10px 0;
padding-left: 12px;
color: var(--settings-subtitle-color, #cbd5e1);
border-left: 3px solid var(--fill-accent, #0ea5e9);
}
.changelog-body a {
color: var(--fill-accent, #0ea5e9);
}
.changelog-loading,
.changelog-empty {
margin: 0;
color: var(--settings-subtitle-color, #cbd5e1);
}
@media (max-width: 780px) {
.changelog-overlay {
padding: 12px;
}
.changelog-header,
.changelog-list {
padding-left: 18px;
padding-right: 18px;
}
.changelog-entry-heading {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
}

View File

@@ -14,8 +14,8 @@
.custom-controls { .custom-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
padding-left: 12px; padding-left: 10px;
} }
/* RIGHT - window buttons */ /* RIGHT - window buttons */
@@ -43,11 +43,11 @@
/* Custom buttons (Logs / Wiki / etc.) */ /* Custom buttons (Logs / Wiki / etc.) */
.custom-controls button { .custom-controls button {
height: 32px; height: 28px;
padding: 0 14px; padding: 0 10px;
font-size: 15px; font-size: 14px;
border-radius: 8px; border-radius: 7px;
} }
.custom-controls button:hover { .custom-controls button:hover {

View File

@@ -13,6 +13,7 @@
@import url("components/progressBar.css"); @import url("components/progressBar.css");
@import url("components/toast.css"); @import url("components/toast.css");
@import url("components/autoUpdate.css"); @import url("components/autoUpdate.css");
@import url("components/changelog.css");
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');

View File

@@ -1 +0,0 @@
[//]: # (This file is used for the release CHANGELOG. Please use Markdown to describe changes.)

View File

@@ -0,0 +1,24 @@
### 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.
- **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
- **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 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.
- **Code Quality**: Implemented a linter to enforce coding standards.
### Fixes
- **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 handle the process natively.
- **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.
- **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
- **Dependencies**: Removed `dotenv` from the project.
- **Website**: A brand new website URL is now available: [freedomloader.acnolo.fr](https://freedomloader.acnolo.fr)
- **Dev**: Added a new npm script to check Linux file quality.

View File

@@ -54,20 +54,6 @@ exec /opt/freedom-loader/freedom-loader "$@"
EOF EOF
chmod +x "$TMP_WRAPPER/freedom-loader" 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 # Step 1 - Build .rpm from linux-unpacked via fpm
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -78,7 +64,7 @@ echo "Building RPM..."
--version "$VERSION" \ --version "$VERSION" \
--architecture x86_64 \ --architecture x86_64 \
--description "Free and open-source GUI for yt-dlp" \ --description "Free and open-source GUI for yt-dlp" \
--url "https://masteracnolo.github.io/Freedom-Loader-Site/" \ --url "https://freedomloader.acnolo.fr/" \
--maintainer "MasterAcnolo <MasterAcnolo@users.noreply.github.com>" \ --maintainer "MasterAcnolo <MasterAcnolo@users.noreply.github.com>" \
--license "GPL-3.0-only" \ --license "GPL-3.0-only" \
--depends gtk3 \ --depends gtk3 \
@@ -89,11 +75,10 @@ echo "Building RPM..."
--package "$RPM_OUT" \ --package "$RPM_OUT" \
"$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \ "$ROOT_DIR/dist/linux-unpacked/=/opt/freedom-loader" \
"$TMP_WRAPPER/freedom-loader=/usr/bin/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/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" "$ROOT_DIR/package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml" \
"$ROOT_DIR/package/com.masteracnolo.freedomloader.desktop=/usr/share/applications/com.masteracnolo.freedomloader.desktop"
rm -rf "$TMP_WRAPPER" "$TMP_DESKTOP" rm -rf "$TMP_WRAPPER"
echo "RPM built: $RPM_OUT" echo "RPM built: $RPM_OUT"
# ------------------------------------------------------------ # ------------------------------------------------------------

View File

@@ -67,11 +67,11 @@ echo "Workspace cleaned (dist/ and srpm-out/ removed)"
# ------------------------------------------------------------ # ------------------------------------------------------------
# Step 0.3 - Retrieve Changelog # Step 0.3 - Retrieve Changelog
# ------------------------------------------------------------ # ------------------------------------------------------------
if [ ! -f "$ROOT_DIR/CHANGELOG.md" ]; then if [ ! -f "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md" ]; then
echo "Warning: CHANGELOG.md not found, release notes will be empty." echo "Warning: RELEASE-CHANGELOG.md not found, release notes will be empty."
CHANGELOG="" CHANGELOG=""
else else
CHANGELOG="$(cat "$ROOT_DIR/CHANGELOG.md")" CHANGELOG="$(cat "$ROOT_DIR/scripts/RELEASE-CHANGELOG.md")"
echo "Changelog retrieved successfully" echo "Changelog retrieved successfully"
fi fi
# ------------------------------------------------------------ # ------------------------------------------------------------

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);
res.end(); if (index >= 0) progressListeners.splice(index, 1);
progressEmitter.removeListener("progress", onProgress);
try {
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);
res.end(); if (index >= 0) speedListeners.splice(index, 1);
speedEmitter.removeListener("speed", onSpeed);
try {
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);
res.end(); if (index >= 0) stageListeners.splice(index, 1);
stageEmitter.removeListener("stage", onStage);
try {
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", () => {
res.end(); const index = playlistListeners.indexOf(sendPlaylistInfo);
if (index >= 0) playlistListeners.splice(index, 1);
playlistEmitter.removeListener("playlist", onPlaylist);
try {
res.end();
} catch (err) {
// ignore
}
}); });
} }
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController}; module.exports = {
downloadController,
progressController,
speedController,
stageController,
cancelDownloadController,
playlistInfoController,
};

View File

@@ -1,130 +1,42 @@
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}`);
logger.info(`Info request received. ENCODED: ${encodedUrl}`); if (encodedUrl) {
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
// Lightweight heuristic to detect playlist URLs. It works for Youtube.
const isPlaylistUrl = isUrlPlaylist(url);
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) {
/**
* 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(
`Info controller error: ${err && err.message ? err.message : err}`
);
return res.status(500).send("Unable to fetch info");
} }
const result = await fetchNormalizedInfo(url);
return res.json(result);
} catch (err) {
logger.error(
`Info controller error: ${err && err.message ? err.message : err}`
);
return res.status(500).json({
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
});
}
} }
module.exports = { infoController }; module.exports = { infoController };

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,54 +23,117 @@ const { logger } = require("../logger");
* *
*/ */
function fetchInfo(url) { function fetchInfo(url) {
/**
* Base yt-dlp arguments used to retrieve metadata
* without downloading media files.
*/
const metadataArgs = [
"--js-runtimes",
`deno:${denoPath}`,
"--dump-single-json",
"--ignore-errors",
"--yes-playlist",
"--cookies-from-browser",
`${getUserBrowser()}`,
"--extractor-args",
"youtube:player_client=default",
"--ignore-no-formats-error",
];
/** return new Promise((resolve, reject) => {
* Base yt-dlp arguments used to retrieve metadata execFile(
* without downloading media files. userYtDlp,
*/ [...metadataArgs, url],
const metadataArgs = [ {
"--js-runtimes", `deno:${denoPath}`, timeout: 30_000, // 30s, if more timeout
"--dump-single-json", maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
"--ignore-errors", },
"--yes-playlist", (error, stdout, stderr) => {
"--cookies-from-browser",`${getUserBrowser()}`, if (stderr) {
"--extractor-args","youtube:player_client=default", const lower = stderr?.toLowerCase();
"--ignore-no-formats-error", if (
]; lower.includes("sign in to confirm") ||
lower.includes("failed to decrypt") ||
return new Promise((resolve, reject) => { lower.includes("could not copy")
) {
execFile(userYtDlp,[...metadataArgs , url], return reject(
{ timeout: 30_000, // 30s, if more timeout new Error(
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller) `Unable to extract cookies from ${getUserBrowser()}. Please log in to this browser and try again.`
)
(error, stdout, stderr) => { );
if (stderr) {
const lower = stderr.toLowerCase();
if (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.`));
}
} }
}
if (error) { if (error) {
logger.error(`YT-DLP Error: ${error.message}`); logger.error(`YT-DLP Error: ${error.message}`);
if (stderr) logger.debug(`stderr: ${stderr}`); if (stderr) logger.debug(`stderr: ${stderr}`);
return reject(new Error("Unable to fetch Info.")); return reject(new Error("Unable to fetch Info."));
} }
if (!stdout) return reject(new Error("No Data Received.")); if (!stdout) return reject(new Error("No Data Received."));
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();
expect(shell.openPath).toHaveBeenCalledWith("/downloads"); setTimeout(() => {
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);
});
});
});