Merge pull request #46 from MasterAcnolo/1.6.1

1.6.1
This commit is contained in:
Axel Nicolas
2026-08-07 22:27:33 +02:00
committed by GitHub
20 changed files with 5284 additions and 2513 deletions

View File

@@ -219,7 +219,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
#### Linux #### Linux
``` ```
~/.config/Freedom Loader/config.json ~/.config/Freedom\ Loader/config.json
``` ```
### Available Options ### Available Options
@@ -233,6 +233,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true, "createPlaylistFolders": true,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,
@@ -256,6 +257,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
| `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 |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists | | `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist | | `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) | | `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging | | `logSystem` | boolean | `true` | Enable application logging |
@@ -276,7 +278,7 @@ Freedom Loader can be configured either through the settings panel in the UI or
- `theora` - Theora (legacy open codec) - `theora` - Theora (legacy open codec)
> [!NOTE] > [!NOTE]
> Configuration changes may require an application restart to take effect. > Some configuration changes may require an application restart to take effect.
## Project Structure ## Project Structure

View File

@@ -34,6 +34,7 @@ const FEATURE_WHITELIST = new Set([
"addMetadata", "addMetadata",
"verboseLogs", "verboseLogs",
"autoDownloadPlaylist", "autoDownloadPlaylist",
"keepPlaylistOrder",
"customCodec", "customCodec",
"theme", "theme",
"createPlaylistFolders", "createPlaylistFolders",

View File

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

View File

@@ -7,6 +7,7 @@
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true, "createPlaylistFolders": true,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,

21
jest.config.js Normal file
View File

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

6996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "freedom-loader", "name": "freedom-loader",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"version": "1.6.0", "version": "1.6.1",
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>", "author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms", "description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
"homepage": "https://masteracnolo.github.io/Freedom-Loader-Site/", "homepage": "https://masteracnolo.github.io/Freedom-Loader-Site/",
@@ -16,12 +16,19 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron --trace-warnings .", "start": "electron --trace-warnings .",
"postinstall": "chmod +x resources/binaries/linux/* 2>/dev/null || true",
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win", "build:win": "electron-builder --win",
"build:linux": "electron-builder --linux", "build:linux": "electron-builder --linux",
"build:rpm": "bash scripts/create-copr-package.sh", "build:rpm": "bash scripts/create-copr-package.sh",
"build:all": "electron-builder -wl", "build:all": "electron-builder -wl",
"release": "bash scripts/release.sh",
"release:publish": "bash scripts/release.sh --publish",
"release:dry-run": "bash scripts/release.sh --dry-run",
"test": "jest",
"test:unit": "jest test/unit",
"update": "npm update" "update": "npm update"
}, },
"dependencies": { "dependencies": {
@@ -38,8 +45,12 @@
}, },
"devDependencies": { "devDependencies": {
"@electron/devtron": "^2.1.1", "@electron/devtron": "^2.1.1",
"electron": "^41.2.0", "@jest/globals": "^30.4.1",
"electron-builder": "^25.1.8" "@playwright/test": "^1.62.1",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
"jest": "^30.4.2",
"playwright": "^1.62.1"
}, },
"build": { "build": {
"appId": "com.masteracnolo.freedomloader", "appId": "com.masteracnolo.freedomloader",
@@ -91,7 +102,7 @@
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "build/app-icon.ico", "icon": "build/app-icon.ico",
"artifactName": "${productName}-Setup-${version}.${ext}", "artifactName": "Freedom-Loader-Setup-${version}.${ext}",
"extraResources": [ "extraResources": [
{ {
"from": "resources/binaries/win-32/yt-dlp.exe", "from": "resources/binaries/win-32/yt-dlp.exe",

View File

@@ -106,10 +106,10 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="autoCheckInfo" id="autoCheckInfo"> <input data-key="keepPlaylistOrder" id="keepPlaylistOrder" type="checkbox">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Auto Fetch Info</span> <span class="setting-title">Keep Playlist Order</span>
<small>Automatically fetch information for new downloads.</small> <small>Add index before output file, to keep the order.</small>
</div> </div>
</div> </div>
</div> </div>
@@ -135,7 +135,15 @@
</div> </div>
</div> --> </div> -->
<div class="setting-item"> <div class="setting-item">
<input data-key="autoCheckInfo" id="autoCheckInfo" type="checkbox">
<div class="setting-info">
<span class="setting-title">Auto Fetch Info</span>
<small>Automatically fetch information for new downloads.</small>
</div>
</div>
<div class="setting-item">
<input type="checkbox" data-key="addMetadata" id="addMetadata"> <input type="checkbox" data-key="addMetadata" id="addMetadata">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Add Metadata</span> <span class="setting-title">Add Metadata</span>

View File

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

View File

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

187
scripts/release.sh Executable file
View File

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

View File

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

View File

@@ -62,7 +62,7 @@ function validateCodec(codec){
* *
* @returns {string[]} Array of arguments ready to be passed to yt-dlp. * @returns {string[]} Array of arguments ready to be passed to yt-dlp.
*/ */
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) { function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
logger.info("--- CONFIGURATION ---"); logger.info("--- CONFIGURATION ---");
@@ -70,6 +70,7 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`); logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`); logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`); logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`); logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`); logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`);
logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`); logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`);
@@ -117,7 +118,19 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
}; };
args.push("-f", qualityMap[quality] || "best"); args.push("-f", qualityMap[quality] || "best");
args.push("-o", path.join(outputFolder, "%(title)s.%(ext)s"));
/**
* Default title output template
* @type {string}
*/
let outputTemplate = "%(title)s.%(ext)s";
// If it's a playlist, and we want to keep the playlist order. Add an index before the title.
if (isPlaylist && configFeatures.keepPlaylistOrder && configFeatures.autoDownloadPlaylist) {
outputTemplate = "%(playlist_index)02d - %(title)s.%(ext)s";
}
args.push("-o", path.join(outputFolder, outputTemplate));
args.push(url); args.push(url);
return args.filter(Boolean); return args.filter(Boolean);

View File

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

View File

@@ -55,6 +55,7 @@ const logger = createLogger({
*/ */
function logSessionStart(logDir, downloadPath) { function logSessionStart(logDir, downloadPath) {
logger.info(`--- Starting session: ${new Date().toISOString()} ---`); logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
logger.info("============================================================")
logger.info(`Application Version: ${config.version}`) logger.info(`Application Version: ${config.version}`)
logSystemInfo(logger, logDir, downloadPath) logSystemInfo(logger, logDir, downloadPath)
} }

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
const notify = require("../helpers/notify.helpers"); const notify = require("../helpers/notify.helpers");
const path = require("path"); const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers"); const { isSafePath } = require("../helpers/validation.helpers");
const { configFeatures } = require("../../config.js"); const {reloadFeatures} = require("../../config");
/** /**
* Reference to the currently running yt-dlp process. * Reference to the currently running yt-dlp process.
@@ -135,7 +135,10 @@ function createPlaylistFolder(basePath, playlistTitle) {
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) { function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
const userConfig = reloadFeatures();
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
let outputFolder = options.outputFolder || defaultDownloadFolder; let outputFolder = options.outputFolder || defaultDownloadFolder;
@@ -158,8 +161,8 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
// Détecte si c'est une playlist et crée un dossier approprié // Détecte si c'est une playlist et crée un dossier approprié
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url); const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
if (isPlaylist && configFeatures.createPlaylistFolders) { if (isPlaylist && userConfig.createPlaylistFolders) {
try { try {
const playlistName = options.playlistTitle || "Untitled Playlist"; const playlistName = options.playlistTitle || "Untitled Playlist";
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName); safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
@@ -168,11 +171,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
logger.error(`Failed to create playlist folder: ${err.message}`); logger.error(`Failed to create playlist folder: ${err.message}`);
return reject(err); return reject(err);
} }
} else if (isPlaylist && !configFeatures.createPlaylistFolders) { } else if (isPlaylist && !userConfig.createPlaylistFolders) {
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`); logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
} }
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder }); const args = buildYtDlpArgs({
...options,
outputFolder: safeOutputFolder,
isPlaylist
});
logger.info(`[yt-dlp args] ${args.join(" ")}`); logger.info(`[yt-dlp args] ${args.join(" ")}`);
const child = execFile(userYtDlp, args); const child = execFile(userYtDlp, args);

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

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

View File

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