mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 15:09:20 +01:00
Compare commits
19 Commits
main
...
f650770df7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f650770df7 | ||
|
|
f0f3018f80 | ||
|
|
061c6b3b66 | ||
|
|
c9bb529b47 | ||
|
|
8c3e1135e5 | ||
|
|
fa5776b254 | ||
|
|
ef68484828 | ||
|
|
fdfe30e796 | ||
|
|
0699656394 | ||
|
|
fbe30f7ee2 | ||
|
|
0da6df03cb | ||
|
|
650696b9f5 | ||
|
|
634108784e | ||
|
|
1b83272aaf | ||
|
|
dc898e1235 | ||
|
|
7c5ccb1527 | ||
|
|
f5259f7ade | ||
|
|
1fcf7cd988 | ||
|
|
2e343cbd0c |
@@ -210,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
|
||||||
@@ -231,6 +234,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
|||||||
"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,
|
||||||
@@ -255,6 +259,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
|
|||||||
| `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 |
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const FEATURE_WHITELIST = new Set([
|
|||||||
"addThumbnail",
|
"addThumbnail",
|
||||||
"addMetadata",
|
"addMetadata",
|
||||||
"verboseLogs",
|
"verboseLogs",
|
||||||
|
"autoUpdateYtDlp",
|
||||||
"autoDownloadPlaylist",
|
"autoDownloadPlaylist",
|
||||||
"keepPlaylistOrder",
|
"keepPlaylistOrder",
|
||||||
"customCodec",
|
"customCodec",
|
||||||
|
|||||||
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};
|
||||||
62
config.js
62
config.js
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"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,
|
||||||
|
|||||||
5
main.js
5
main.js
@@ -145,7 +145,12 @@ app.whenReady().then(async () => {
|
|||||||
if (!devMode && !checkNativeDependencies()) return;
|
if (!devMode && !checkNativeDependencies()) return;
|
||||||
|
|
||||||
const { userYtDlp } = require("./server/helpers/path.helpers");
|
const { userYtDlp } = require("./server/helpers/path.helpers");
|
||||||
|
|
||||||
|
if (configFeatures.autoUpdateYtDlp !== false) {
|
||||||
updateYtDlp(userYtDlp);
|
updateYtDlp(userYtDlp);
|
||||||
|
} else {
|
||||||
|
logger.info("yt-dlp automatic updates skipped.");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSplashProgress(0); // Checking dependencies
|
setSplashProgress(0); // Checking dependencies
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "freedom-loader",
|
"name": "freedom-loader",
|
||||||
"desktopName": "com.masteracnolo.freedomloader",
|
"desktopName": "com.masteracnolo.freedomloader",
|
||||||
"productName": "Freedom Loader",
|
"productName": "Freedom Loader",
|
||||||
"version": "1.6.4",
|
"version": "1.6.5",
|
||||||
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
|
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
|
||||||
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
|
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
|
||||||
"homepage": "https://freedomloader.acnolo.fr/",
|
"homepage": "https://freedomloader.acnolo.fr/",
|
||||||
@@ -67,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.masteracnolo.freedomloader",
|
"appId": "com.masteracnolo.freedomloader",
|
||||||
"artifactName": "Freedom-Loader-${version}.${ext}",
|
|
||||||
"productName": "Freedom Loader",
|
"productName": "Freedom Loader",
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
@@ -86,7 +85,12 @@
|
|||||||
"config.js",
|
"config.js",
|
||||||
"main.js",
|
"main.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"!node_modules/**/README",
|
||||||
|
"!node_modules/**/*.md",
|
||||||
|
"!node_modules/**/*.map",
|
||||||
|
"!**/test/**",
|
||||||
|
"!**/__tests__/**"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
@@ -176,6 +180,9 @@
|
|||||||
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
|
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"appImage": {
|
||||||
|
"artifactName": "Freedom-Loader-${version}.${ext}"
|
||||||
|
},
|
||||||
"snap": {
|
"snap": {
|
||||||
"confinement": "strict",
|
"confinement": "strict",
|
||||||
"plugs": [
|
"plugs": [
|
||||||
|
|||||||
12
preload.js
12
preload.js
@@ -159,6 +159,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
ipcRenderer.on("update-downloaded", (_, info) => callback(info)),
|
ipcRenderer.on("update-downloaded", (_, info) => callback(info)),
|
||||||
|
|
||||||
openReleasePage: () => ipcRenderer.send("open-release-page"),
|
openReleasePage: () => ipcRenderer.send("open-release-page"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to toast notifications emitted by the main process.
|
||||||
|
*
|
||||||
|
* @param {Function} callback - Function executed with toast data.
|
||||||
|
* @returns {Function} Removes the listener.
|
||||||
|
*/
|
||||||
|
onToast: (callback) => {
|
||||||
|
const listener = (_, toast) => callback(toast);
|
||||||
|
ipcRenderer.on("show-toast", listener);
|
||||||
|
return () => ipcRenderer.removeListener("show-toast", listener);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -217,6 +217,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<input data-key="autoUpdateYtDlp" id="autoUpdateYtDlp" type="checkbox">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-title">Automatically Update yt-dlp</span>
|
||||||
|
<small>Check for and install yt-dlp updates when the application starts.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="settings-footer">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -132,7 +132,7 @@ if [ "$DRY_RUN" = false ]; then
|
|||||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
|
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe" \
|
||||||
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
|
"$ROOT_DIR/dist/Freedom-Loader-Setup-${VERSION}.exe.blockmap" \
|
||||||
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
|
"$ROOT_DIR/dist/freedom-loader-${VERSION}.x86_64.rpm" \
|
||||||
"$ROOT_DIR/dist/Freedom-Loader_${VERSION}_amd64.deb" \
|
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.deb" \
|
||||||
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
|
"$ROOT_DIR/dist/Freedom-Loader-${VERSION}.AppImage" \
|
||||||
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
"$ROOT_DIR/dist/freedom-loader_${VERSION}_amd64.snap" \
|
||||||
"$ROOT_DIR/dist/latest.yml" \
|
"$ROOT_DIR/dist/latest.yml" \
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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, () => {
|
||||||
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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 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/"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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