mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 23:19:21 +01:00
Compare commits
28 Commits
f0971ebccc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787cc69600 | ||
|
|
f650770df7 | ||
|
|
f0f3018f80 | ||
|
|
061c6b3b66 | ||
|
|
c9bb529b47 | ||
|
|
8c3e1135e5 | ||
|
|
fa5776b254 | ||
|
|
ef68484828 | ||
|
|
fdfe30e796 | ||
|
|
0699656394 | ||
|
|
fbe30f7ee2 | ||
|
|
0da6df03cb | ||
|
|
650696b9f5 | ||
|
|
634108784e | ||
|
|
1b83272aaf | ||
|
|
dc898e1235 | ||
|
|
7c5ccb1527 | ||
|
|
f5259f7ade | ||
|
|
1fcf7cd988 | ||
|
|
2e343cbd0c | ||
|
|
69de9eb9fc | ||
|
|
cfa85649b3 | ||
|
|
773ed9550d | ||
|
|
ea3a475525 | ||
|
|
738d5ec62f | ||
|
|
aff7637358 | ||
|
|
7ee6d6fbe7 | ||
|
|
6aa3a39819 |
16
README.md
16
README.md
@@ -56,7 +56,7 @@ The primary goal is to make media downloading accessible to users who want offli
|
||||
|
||||
### User Interface
|
||||
|
||||
- **Custom window controls** - Frameless window with custom top bar (optional)
|
||||
- **Custom window controls** - Frameless window with custom top bar
|
||||
- **Custom themes** - ZIP-based theme system with live preview
|
||||
- **Theme Workshop** - web-based theme creator and browser
|
||||
- **Toast notifications** - in-app feedback system
|
||||
@@ -151,6 +151,7 @@ freedom-loader
|
||||
|---------------|---------------------------------------------------------|--------------------------------------|
|
||||
| **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
|
||||
| **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
|
||||
| **Cache** | ``
|
||||
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
|
||||
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
|
||||
|
||||
#### Windows
|
||||
@@ -224,14 +228,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
### Available Options
|
||||
```json
|
||||
{
|
||||
"autoUpdate": true,
|
||||
"systemTray": true,
|
||||
"discordRPC": true,
|
||||
"customTopBar": true,
|
||||
"autoCheckInfo": true,
|
||||
"addThumbnail": true,
|
||||
"addMetadata": true,
|
||||
"verboseLogs": false,
|
||||
"autoUpdateYtDlp": true,
|
||||
"autoDownloadPlaylist": true,
|
||||
"keepPlaylistOrder": false,
|
||||
"createPlaylistFolders": true,
|
||||
@@ -241,6 +244,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
"downloadSystem": true,
|
||||
"notifySystem": true,
|
||||
"enableHardwareAcceleration": true,
|
||||
"showChanglogsAfterUpdate": true,
|
||||
"theme": "dark"
|
||||
}
|
||||
```
|
||||
@@ -249,14 +253,13 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|------------------------------|---------|----------|------------------------------------------------------------|
|
||||
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
|
||||
| `systemTray` | boolean | `false` | Allow app to minimize on close [EXPERIMENTAL] |
|
||||
| `systemTray` | boolean | `false` | Allow app to minimize on close |
|
||||
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
|
||||
| `customTopBar` | boolean | `true` | Use custom application top bar |
|
||||
| `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
|
||||
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
|
||||
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
|
||||
| `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 |
|
||||
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
|
||||
| `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 |
|
||||
| `notifySystem` | boolean | `true` | Enable system notifications |
|
||||
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
|
||||
| `showChanglogsAfterUpdate` | boolean | `true` | Auto-display changelog popup after and update |
|
||||
| `theme` | string | `dark` | Current Theme Name |
|
||||
|
||||
### Supported Codecs
|
||||
|
||||
6
app/ipcHandlers.js
Normal file → Executable file
6
app/ipcHandlers.js
Normal file → Executable file
@@ -30,21 +30,21 @@ const { getChangelog, markChangelogSeen } = require("./changelogManager");
|
||||
* Acts as a basic validation layer for IPC "set-feature".
|
||||
*/
|
||||
const FEATURE_WHITELIST = new Set([
|
||||
"autoUpdate",
|
||||
"systemTray",
|
||||
"discordRPC",
|
||||
"customTopBar",
|
||||
"autoCheckInfo",
|
||||
"addThumbnail",
|
||||
"addMetadata",
|
||||
"verboseLogs",
|
||||
"autoUpdateYtDlp",
|
||||
"autoDownloadPlaylist",
|
||||
"keepPlaylistOrder",
|
||||
"customCodec",
|
||||
"theme",
|
||||
"createPlaylistFolders",
|
||||
"notifySystem",
|
||||
"enableHardwareAcceleration"
|
||||
"enableHardwareAcceleration",
|
||||
"showChanglogsAfterUpdate"
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
22
app/toastNotifier.js
Normal file
22
app/toastNotifier.js
Normal 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};
|
||||
@@ -57,7 +57,7 @@ async function createMainWindow() {
|
||||
height: 800,
|
||||
minWidth: 750,
|
||||
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,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
|
||||
62
config.js
62
config.js
@@ -1,5 +1,10 @@
|
||||
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 path = require("path");
|
||||
|
||||
@@ -7,7 +12,7 @@ const path = require("path");
|
||||
* Indicates whether the application is running in development mode.
|
||||
* 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.
|
||||
@@ -57,6 +62,40 @@ function 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.
|
||||
*
|
||||
@@ -64,8 +103,15 @@ const featuresPath = resolveConfigPath();
|
||||
* @throws {Error} If the configuration file is missing or invalid
|
||||
*/
|
||||
function loadFeatures() {
|
||||
const raw = fs.readFileSync(featuresPath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
const defaults = JSON.parse(fs.readFileSync(getDefaultConfigPath(), "utf-8"));
|
||||
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() {
|
||||
try {
|
||||
const raw = fs.readFileSync(featuresPath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
return loadFeatures();
|
||||
} catch (err) {
|
||||
// Fallback to cached snapshot if file read fails
|
||||
return configFeatures;
|
||||
@@ -128,4 +173,9 @@ module.exports = {
|
||||
* Path to the active configuration file
|
||||
*/
|
||||
featuresPath,
|
||||
|
||||
/**
|
||||
* Merges missing default feature flags into user configuration without overwriting existing settings.
|
||||
*/
|
||||
mergeMissingFeatures,
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"autoUpdate": true,
|
||||
"systemTray": false,
|
||||
"discordRPC": true,
|
||||
"customTopBar": true,
|
||||
"autoCheckInfo": true,
|
||||
"addThumbnail": true,
|
||||
"addMetadata": true,
|
||||
"verboseLogs": false,
|
||||
"autoUpdateYtDlp": true,
|
||||
"autoDownloadPlaylist": true,
|
||||
"keepPlaylistOrder": false,
|
||||
"createPlaylistFolders": false,
|
||||
@@ -16,5 +15,6 @@
|
||||
"downloadSystem": true,
|
||||
"notifySystem": true,
|
||||
"enableHardwareAcceleration": true,
|
||||
"showChanglogsAfterUpdate": true,
|
||||
"theme": "dark"
|
||||
}
|
||||
7
main.js
7
main.js
@@ -145,7 +145,12 @@ app.whenReady().then(async () => {
|
||||
if (!devMode && !checkNativeDependencies()) return;
|
||||
|
||||
const { userYtDlp } = require("./server/helpers/path.helpers");
|
||||
|
||||
if (configFeatures.autoUpdateYtDlp !== false) {
|
||||
updateYtDlp(userYtDlp);
|
||||
} else {
|
||||
logger.info("yt-dlp automatic updates skipped.");
|
||||
}
|
||||
|
||||
try {
|
||||
setSplashProgress(0); // Checking dependencies
|
||||
@@ -173,7 +178,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (configFeatures.discordRPC) startRPC();
|
||||
|
||||
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow());
|
||||
initAutoUpdater(getMainWindow());
|
||||
|
||||
} catch (err) {
|
||||
logger.error("Boot error:", err);
|
||||
|
||||
13
package.json
13
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "freedom-loader",
|
||||
"desktopName": "com.masteracnolo.freedomloader",
|
||||
"productName": "Freedom Loader",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.5",
|
||||
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
|
||||
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
|
||||
"homepage": "https://freedomloader.acnolo.fr/",
|
||||
@@ -67,7 +67,6 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.masteracnolo.freedomloader",
|
||||
"artifactName": "Freedom-Loader-${version}.${ext}",
|
||||
"productName": "Freedom Loader",
|
||||
"publish": [
|
||||
{
|
||||
@@ -86,7 +85,12 @@
|
||||
"config.js",
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"!node_modules/**/README",
|
||||
"!node_modules/**/*.md",
|
||||
"!node_modules/**/*.map",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "build"
|
||||
@@ -176,6 +180,9 @@
|
||||
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
|
||||
]
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Freedom-Loader-${version}.${ext}"
|
||||
},
|
||||
"snap": {
|
||||
"confinement": "strict",
|
||||
"plugs": [
|
||||
|
||||
12
preload.js
12
preload.js
@@ -159,6 +159,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.on("update-downloaded", (_, info) => callback(info)),
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -124,10 +124,10 @@
|
||||
<summary>Advanced Settings</summary>
|
||||
|
||||
<div class="setting-item">
|
||||
<input type="checkbox" data-key="autoUpdate" id="autoUpdate">
|
||||
<input type="checkbox" data-key="showChanglogsAfterUpdate" id="showChanglogsAfterUpdate">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Auto Update</span>
|
||||
<small>Enable automatic app updates.</small>
|
||||
<span class="setting-title">Show Changelogs After Update</span>
|
||||
<small>Display changelogs after each update.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
<input data-key="systemTray" id="systemTray" type="checkbox">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">System Tray</span>
|
||||
<small>Minimize app on close, in the System Tray. [EXPERIMENTAL]</small>
|
||||
<small>Minimize app on close, in the System Tray.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +171,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<!-- <div class="setting-item">
|
||||
<input type="checkbox" data-key="customTopBar" id="customTopBar">
|
||||
<div class="setting-info">
|
||||
<span class="setting-title">Custom Top bar</span>
|
||||
<small>Use this if the topbar is missing or you prefer a custom one.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-item" style="margin-left: 32px; margin-top: 20px;">
|
||||
<div class="setting-info">
|
||||
@@ -217,6 +217,14 @@
|
||||
</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>
|
||||
|
||||
<div class="settings-footer">
|
||||
|
||||
7
public/script/changelog.js
Normal file → Executable file
7
public/script/changelog.js
Normal file → Executable file
@@ -226,10 +226,15 @@ async function closeChangelog() {
|
||||
|
||||
/** 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) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -53,17 +53,32 @@ form.addEventListener("submit", async (e) => {
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams(formData);
|
||||
const body = Object.fromEntries(params.entries());
|
||||
|
||||
try {
|
||||
const res = await fetch("/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
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);
|
||||
resetProgressBar();
|
||||
}
|
||||
@@ -71,19 +86,27 @@ form.addEventListener("submit", async (e) => {
|
||||
}
|
||||
|
||||
// Download successful - check if notifications are enabled
|
||||
try {
|
||||
const features = await window.electronAPI.getFeatures();
|
||||
if (features.notifySystem) {
|
||||
window.showSuccess("Download completed!");
|
||||
}
|
||||
} catch (e) {
|
||||
window.showSuccess("Download completed!");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (!isDownloading && !userStoppedDownload) {
|
||||
statusDiv.style.display = "none";
|
||||
}
|
||||
if (!userStoppedDownload) {
|
||||
window.showError("Network error while starting download");
|
||||
resetProgressBar();
|
||||
}
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
button.disabled = false;
|
||||
stopBtn.style.display = "none";
|
||||
if (stopBtn) stopBtn.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,7 +158,7 @@ async function stopDownload() {
|
||||
try {
|
||||
await fetch("/download/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {"Content-Type": "application/json"},
|
||||
});
|
||||
|
||||
window.showWarning("Download stopped");
|
||||
|
||||
@@ -32,14 +32,27 @@ async function fetchVideoInfo(url) {
|
||||
try {
|
||||
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
|
||||
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.error) {
|
||||
return {error: data.error.message || data.error};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
|
||||
@@ -83,3 +83,12 @@ window.showWarning = (message, duration = 4000) =>
|
||||
*/
|
||||
window.showInfo = (message, duration = 4000) =>
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -35,25 +35,26 @@ export function initTopBar() {
|
||||
|
||||
}
|
||||
|
||||
async function attachListeners(){
|
||||
try{
|
||||
const featuresList = await window.electronAPI.getFeatures();
|
||||
//* Disabled because i disabled native topbar for now, but keeping the code in case i want to re-enable it later
|
||||
// async function attachListeners(){
|
||||
// try{
|
||||
// const featuresList = await window.electronAPI.getFeatures();
|
||||
|
||||
if (!featuresList.customTopBar) {
|
||||
const topbar = document.getElementById("topbar");
|
||||
const container = document.getElementById("container");
|
||||
const themeSwitcher = document.getElementById("theme-switcher");
|
||||
// if (!featuresList.customTopBar) {
|
||||
// const topbar = document.getElementById("topbar");
|
||||
// const container = document.getElementById("container");
|
||||
// const themeSwitcher = document.getElementById("theme-switcher");
|
||||
|
||||
if (topbar) topbar.style.display = "none";
|
||||
if (container) container.style.marginTop = "0";
|
||||
if (themeSwitcher) themeSwitcher.style.top = "30px";
|
||||
}
|
||||
// if (topbar) topbar.style.display = "none";
|
||||
// if (container) container.style.marginTop = "0";
|
||||
// if (themeSwitcher) themeSwitcher.style.top = "30px";
|
||||
// }
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load layout features for topbar:", error);
|
||||
}
|
||||
// } catch (error) {
|
||||
// console.error("Failed to load layout features for topbar:", error);
|
||||
// }
|
||||
|
||||
}
|
||||
// }
|
||||
|
||||
initTopBar()
|
||||
document.addEventListener("DOMContentLoaded", attachListeners)
|
||||
//document.addEventListener("DOMContentLoaded", attachListeners)
|
||||
@@ -1,21 +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 50% on specific version.
|
||||
- **Updater UI**: Reworked the update notification for better clarity. Windows and AppImage users can download updates seamlessly in-app, while unsupported Linux package users are gracefully redirected to the manual download page. Flatpak (.rpm) and Snap (.snap) users are not informed, because that updates are handled by their respective package managers.
|
||||
- **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.
|
||||
|
||||
### Bug 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 natively handle the process.
|
||||
### 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.
|
||||
- **URL**: A new URL for the website is now available ! [freedomloader.acnolo.fr](https://freedomloader.acnolo.fr)
|
||||
- **Dev**: Add new npm script. To check linux file quality
|
||||
|
||||
- **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.
|
||||
@@ -132,7 +132,7 @@ if [ "$DRY_RUN" = false ]; then
|
||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
|
||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
|
||||
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
|
||||
"$ROOT_DIR/dist/Freedom-Loader_${VERSION}_amd64.deb" \
|
||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
|
||||
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
|
||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
||||
"$ROOT_DIR/dist/latest.yml" \
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
const EventEmitter = require("events");
|
||||
const { fetchDownload, cancelDownload } = require("../services/download.services");
|
||||
const { logger } = require("../logger");
|
||||
const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
|
||||
const { notifyDownloadFinished } = require("../helpers/notify.helpers");
|
||||
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
|
||||
* download progress updates.
|
||||
*
|
||||
* Each listener receives a percentage value between
|
||||
* 0 and 100 during the download lifecycle.
|
||||
*/
|
||||
const progressListeners = [];
|
||||
|
||||
@@ -31,6 +39,13 @@ const stageListeners = [];
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -47,34 +62,91 @@ const playlistListeners = [];
|
||||
*/
|
||||
async function downloadController(req, res) {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const options = {
|
||||
url: req.body.url,
|
||||
audioOnly: req.body.audioOnly === "1",
|
||||
quality: req.body.quality || "best",
|
||||
outputFolder: req.body.savePath || undefined,
|
||||
playlistTitle: req.body.playlistTitle || undefined,
|
||||
url: body.url,
|
||||
audioOnly: body.audioOnly === "1" || body.audioOnly === true,
|
||||
quality: ALLOWED_QUALITIES.has(body.quality) ? body.quality : "best",
|
||||
outputFolder: body.savePath || body.outputFolder || 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)) {
|
||||
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(
|
||||
options,
|
||||
progressListeners,
|
||||
speedListeners,
|
||||
stageListeners,
|
||||
playlistListeners
|
||||
[progressHandler],
|
||||
[speedHandler],
|
||||
[stageHandler],
|
||||
[playlistHandler]
|
||||
);
|
||||
|
||||
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
|
||||
res.send("Download Done !");
|
||||
|
||||
return res.json({success: true, message: "Download Done !", outputFolder});
|
||||
} catch (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) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
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);
|
||||
const onProgress = (percent) => sendProgress(percent);
|
||||
progressEmitter.on("progress", onProgress);
|
||||
|
||||
req.on('close', () => {
|
||||
progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
|
||||
req.on("close", () => {
|
||||
const index = progressListeners.indexOf(sendProgress);
|
||||
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) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
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);
|
||||
const onSpeed = (speed) => sendSpeed(speed);
|
||||
speedEmitter.on("speed", onSpeed);
|
||||
|
||||
req.on('close', () => {
|
||||
speedListeners.splice(speedListeners.indexOf(sendSpeed), 1);
|
||||
req.on("close", () => {
|
||||
const index = speedListeners.indexOf(sendSpeed);
|
||||
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) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
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);
|
||||
const onStage = (stage) => sendStage(stage);
|
||||
stageEmitter.on("stage", onStage);
|
||||
|
||||
req.on('close', () => {
|
||||
stageListeners.splice(stageListeners.indexOf(sendStage), 1);
|
||||
req.on("close", () => {
|
||||
const index = stageListeners.indexOf(sendStage);
|
||||
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();
|
||||
if (cancelled) {
|
||||
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 {
|
||||
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) {
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
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);
|
||||
const onPlaylist = (info) => sendPlaylistInfo(info);
|
||||
playlistEmitter.on("playlist", onPlaylist);
|
||||
|
||||
req.on('close', () => {
|
||||
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
|
||||
// Remove listener when client disconnects
|
||||
req.on("close", () => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,129 +1,41 @@
|
||||
const { fetchInfo } = require("../services/info.services");
|
||||
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
|
||||
const {fetchNormalizedInfo} = require("../services/info.services");
|
||||
const { logger } = require("../logger");
|
||||
const { isValidUrl } = require("../helpers/validation.helpers");
|
||||
const {isUrlPlaylist} = require("../helpers/playlist.helpers");
|
||||
|
||||
/**
|
||||
* Handles metadata retrieval for a video or playlist.
|
||||
*
|
||||
* This controller:
|
||||
* - Validates incoming URL (query or body)
|
||||
* - Calls yt-dlp metadata extraction service
|
||||
* - Detects playlist vs single video context
|
||||
* - Applies fallback strategy if playlist extraction fails
|
||||
* - Returns normalized parsed response (video or playlist)
|
||||
* Returns normalized JSON responses. Accepts URL via query (?url=...)
|
||||
* or request body for flexibility.
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} 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)) {
|
||||
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}`);
|
||||
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);
|
||||
const result = await fetchNormalizedInfo(url);
|
||||
return res.json(result);
|
||||
} 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");
|
||||
return res.status(500).json({
|
||||
error: {code: "FETCH_FAILED", message: "Unable to fetch info"},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,7 @@ function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
|
||||
|
||||
logger.info("--- CONFIGURATION ---");
|
||||
|
||||
logger.info(`CONFIG autoUpdate: ${configFeatures.autoUpdate}`);
|
||||
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
|
||||
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
|
||||
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
|
||||
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
|
||||
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
const notify = require("./notify.helpers");
|
||||
const { logger } = require("../logger");
|
||||
const {sendToast} = require("../../app/toastNotifier");
|
||||
|
||||
/**
|
||||
* Detects the first supported browser installed on the system.
|
||||
@@ -15,6 +16,7 @@ const { logger } = require("../logger");
|
||||
* If no supported browser is found:
|
||||
* - A warning is logged
|
||||
* - 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
|
||||
* handle the error gracefully without crashing the application
|
||||
*
|
||||
@@ -51,6 +53,11 @@ function getUserBrowser() {
|
||||
// No supported browser found => Notify User
|
||||
logger.warn("No supported browser found on the system");
|
||||
notify.notifyFirefoxBrowserMissing();
|
||||
|
||||
sendToast(
|
||||
"Firefox was not found on your system. Please install Firefox to import browser cookies.",
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
logger.info("Browser found: firefox");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { app } = require("electron");
|
||||
let app;
|
||||
try {
|
||||
app = require("electron").app;
|
||||
} catch (e) {
|
||||
app = null;
|
||||
}
|
||||
const config = require("../../config.js");
|
||||
|
||||
/**
|
||||
@@ -16,10 +21,12 @@ const ext = isWindows ? '.exe' : ''
|
||||
/** Centralized resource paths */
|
||||
const resourcesPath = config.devMode
|
||||
? path.join(__dirname, "../../resources")
|
||||
: process.resourcesPath;
|
||||
: (process.resourcesPath || path.join(__dirname, "../../resources"));
|
||||
|
||||
/** Default download folder (centralized) */
|
||||
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader");
|
||||
/** Default download folder */
|
||||
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:
|
||||
@@ -149,7 +156,9 @@ const binaryPaths = {
|
||||
* - default themes come from app resources
|
||||
* - 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");
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const RateLimit = require("express-rate-limit");
|
||||
|
||||
|
||||
/**
|
||||
* Rate limiter middleware used to protect public endpoints
|
||||
* against abusive or excessive requests.
|
||||
@@ -15,10 +14,29 @@ const RateLimit = require("express-rate-limit");
|
||||
const rateLimit = RateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
message: "Too many requests, please try again later.",
|
||||
statusCode: 429,
|
||||
message: {error: {code: "RATE_LIMITED", message: "Too many requests, please try again later."}},
|
||||
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 = {
|
||||
rateLimit
|
||||
}
|
||||
rateLimit,
|
||||
downloadRateLimit,
|
||||
infoRateLimit,
|
||||
};
|
||||
@@ -1,9 +1,17 @@
|
||||
const express = require("express");
|
||||
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("/cancel", cancelDownloadController);
|
||||
router.post("/", downloadRateLimit, downloadController);
|
||||
router.post("/cancel", downloadRateLimit, cancelDownloadController);
|
||||
|
||||
// SSE for download progress
|
||||
router.get("/progress", progressController);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
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;
|
||||
@@ -36,7 +36,7 @@ app.get("/", rateLimit, (req, res) => {
|
||||
*/
|
||||
async function startServer() {
|
||||
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}`);
|
||||
resolve(serverInstance);
|
||||
});
|
||||
@@ -53,11 +53,13 @@ async function startServer() {
|
||||
*/
|
||||
function stopServer() {
|
||||
if (serverInstance) {
|
||||
try{
|
||||
serverInstance.close();
|
||||
try {
|
||||
serverInstance.close(() => {
|
||||
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 {
|
||||
logger.info("Express server was already closed");
|
||||
|
||||
@@ -121,9 +121,11 @@ function createPlaylistFolder(basePath, playlistTitle) {
|
||||
* download has completed successfully.
|
||||
*/
|
||||
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) => {
|
||||
|
||||
const userConfig = reloadFeatures();
|
||||
|
||||
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
|
||||
@@ -171,7 +173,8 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
||||
|
||||
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;
|
||||
|
||||
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
|
||||
@@ -258,21 +261,36 @@ function fetchDownload(options, progressListeners, speedListeners, stageListener
|
||||
function cancelDownload() {
|
||||
if (currentDownloadProcess) {
|
||||
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 to kill with SIGKILL on Windows (process group) or Unix
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, kill the process tree
|
||||
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' });
|
||||
if (process.platform === "win32") {
|
||||
if (pid) {
|
||||
require("child_process").execSync(`taskkill /PID ${pid} /T /F`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// On Unix, send SIGKILL to process group
|
||||
process.kill(-currentDownloadProcess.pid, 'SIGKILL');
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch (err) {
|
||||
logger.warn(`Error killing process group: ${err.message}`);
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
} else {
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Error killing process: ${err.message}`);
|
||||
// Fallback to regular kill
|
||||
currentDownloadProcess.kill('SIGKILL');
|
||||
try {
|
||||
if (currentDownloadProcess && !currentDownloadProcess.killed) {
|
||||
currentDownloadProcess.kill("SIGKILL");
|
||||
}
|
||||
} catch (killErr) {
|
||||
logger.warn(`Fallback kill failed: ${killErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
currentDownloadProcess = null;
|
||||
|
||||
@@ -2,6 +2,8 @@ const { execFile } = require("child_process");
|
||||
const { userYtDlp, denoPath } = require("../helpers/path.helpers");
|
||||
const {getUserBrowser} = require("../helpers/getBrowser.helpers");
|
||||
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.
|
||||
@@ -21,33 +23,44 @@ const { logger } = require("../logger");
|
||||
*
|
||||
*/
|
||||
function fetchInfo(url) {
|
||||
|
||||
/**
|
||||
* Base yt-dlp arguments used to retrieve metadata
|
||||
* without downloading media files.
|
||||
*/
|
||||
const metadataArgs = [
|
||||
"--js-runtimes", `deno:${denoPath}`,
|
||||
"--js-runtimes",
|
||||
`deno:${denoPath}`,
|
||||
"--dump-single-json",
|
||||
"--ignore-errors",
|
||||
"--yes-playlist",
|
||||
"--cookies-from-browser",`${getUserBrowser()}`,
|
||||
"--extractor-args","youtube:player_client=default",
|
||||
"--cookies-from-browser",
|
||||
`${getUserBrowser()}`,
|
||||
"--extractor-args",
|
||||
"youtube:player_client=default",
|
||||
"--ignore-no-formats-error",
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
execFile(userYtDlp,[...metadataArgs , url],
|
||||
{ timeout: 30_000, // 30s, if more timeout
|
||||
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||
|
||||
execFile(
|
||||
userYtDlp,
|
||||
[...metadataArgs, url],
|
||||
{
|
||||
timeout: 30_000, // 30s, if more timeout
|
||||
maxBuffer: 10 * 1024 * 1024, // Maximum response size: 10 MB (Node.js default is much smaller)
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
|
||||
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.`));
|
||||
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.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,14 +74,66 @@ function fetchInfo(url) {
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
logger.error(`JSON Parsing Error: ${e.message}`);
|
||||
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};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const path = require("path");
|
||||
|
||||
jest.mock("../../server/helpers/getBrowser.helpers.js", () =>
|
||||
jest.fn(() => "firefox")
|
||||
);
|
||||
jest.mock("../../server/helpers/getBrowser.helpers.js", () => ({
|
||||
getUserBrowser: jest.fn(() => "firefox"),
|
||||
}));
|
||||
|
||||
jest.mock("../../server/helpers/path.helpers.js", () => ({
|
||||
ffmpegPath: "/mock/ffmpeg",
|
||||
@@ -11,7 +11,6 @@ jest.mock("../../server/helpers/path.helpers.js", () => ({
|
||||
|
||||
jest.mock("../../config.js", () => ({
|
||||
configFeatures: {
|
||||
autoUpdate: false,
|
||||
discordRPC: false,
|
||||
customTopBar: false,
|
||||
autoCheckInfo: false,
|
||||
|
||||
43
test/unit/config.test.js
Normal file
43
test/unit/config.test.js
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
213
test/unit/download.controller.test.js
Normal file
213
test/unit/download.controller.test.js
Normal 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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
test/unit/info.controller.test.js
Normal file
83
test/unit/info.controller.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
161
test/unit/info.services.test.js
Normal file
161
test/unit/info.services.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
const mockShow = jest.fn();
|
||||
const mockOn = jest.fn();
|
||||
const mockOpenPath = jest.fn();
|
||||
const mockOpenPath = jest.fn().mockResolvedValue("");
|
||||
const mockOpenExternal = jest.fn();
|
||||
|
||||
jest.mock("electron", () => ({
|
||||
@@ -71,13 +71,16 @@ describe("notify.helpers", () => {
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("opens folder when notification is clicked", () => {
|
||||
test("opens folder when notification is clicked", (done) => {
|
||||
notifyDownloadFinished("/downloads");
|
||||
|
||||
const callback = mockOn.mock.calls[0][1];
|
||||
callback();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(shell.openPath).toHaveBeenCalledWith("/downloads");
|
||||
done();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +104,7 @@ describe("notify.helpers", () => {
|
||||
callback();
|
||||
|
||||
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();
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
|
||||
"https://www.firefox.com/en-US/download/"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
66
test/unit/parseInfo.helpers.test.js
Normal file
66
test/unit/parseInfo.helpers.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
test/unit/toastNotifier.test.js
Normal file
38
test/unit/toastNotifier.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
40
test/unit/validation.helpers.test.js
Normal file
40
test/unit/validation.helpers.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user