66 Commits

Author SHA1 Message Date
MasterAcnolo
5f28aa502a fix: discord RPC on Linux, creating symlink between Discord and Freedom Loader at start if needed 2026-08-08 10:51:10 +02:00
MasterAcnolo
4a10ef2614 refactor: app display in front end. To make difference more obvious between bundled and not 2026-08-08 10:50:39 +02:00
MasterAcnolo
1296199f65 add: somes startup configuration for dev 2026-08-08 10:50:18 +02:00
MasterAcnolo
764b566699 update: bump version to 1.6.2 2026-08-08 10:12:21 +02:00
MasterAcnolo
0c568c1e8d fix: small fixes in README and update project structure 2026-08-08 10:12:06 +02:00
MasterAcnolo
dae4dd852f add: nodemon configuration for developpment env 2026-08-08 09:28:03 +02:00
MasterAcnolo
e5c5542750 add: nodemon configuration for developpment env 2026-08-08 09:28:01 +02:00
MasterAcnolo
9b2c6c9552 fix: release.sh version format 2026-08-07 23:28:24 +02:00
MasterAcnolo
871ebe2498 refactor: enhance log format by adding color when app is run in an IDE or a Terminal 2026-08-07 23:28:00 +02:00
Axel Nicolas
730069a3c6 Merge pull request #46 from MasterAcnolo/1.6.1
1.6.1
2026-08-07 22:27:33 +02:00
MasterAcnolo
7a0cbd7456 refactor: changed /info router from POST to GET 2026-08-07 21:13:12 +02:00
MasterAcnolo
5d676b45e7 refactor: moved "Auto Fetch Info" settings to advanced settings 2026-08-07 21:06:11 +02:00
MasterAcnolo
a7f3d84a7d fix: log start format and smalls coms fix 2026-08-07 21:04:28 +02:00
MasterAcnolo
fbce5e5b7b update: dependencies 2026-08-05 22:51:55 +02:00
MasterAcnolo
cc7cc38392 feat: automatic release creation with scripts 2026-08-05 22:51:31 +02:00
MasterAcnolo
6a9e5a063b refactor: notify.helpers.js logic. Add a timeout and a better notification management to avoid race condition on Unix Systems 2026-08-05 22:16:15 +02:00
MasterAcnolo
cf8c95711c refactor: notify.helpers.js logic. Add a timeout and a better notification management to avoid race condition on Unix Systems 2026-08-05 22:15:53 +02:00
MasterAcnolo
3683e4245f fix: enhance readme with keepPlaylistOrder information. And fix some typo to fix Linux Env 2026-08-05 22:10:44 +02:00
MasterAcnolo
01affd8ddf feat: Keep Playlist Order features. Add index before title if it's a playlist and we activate this feature. 2026-08-05 21:52:14 +02:00
MasterAcnolo
faba3f516b fix: unit test wrong folder 2026-08-05 21:49:37 +02:00
MasterAcnolo
c1d9448a2b fix: bump version to 1.6.1 and update electron to 43.3.0 2026-08-05 21:49:25 +02:00
MasterAcnolo
1bff8376b4 fix: force artifact name for exe 2026-08-04 22:04:20 +02:00
MasterAcnolo
3f44c01bd7 fix: refresh config at every download. Used by CreatePlaylistFolder 2026-08-04 22:03:55 +02:00
MasterAcnolo
d0bdd887e3 fix: exclude dist, release and build folder from jest config 2026-08-02 21:40:39 +02:00
MasterAcnolo
609ac2eadb fix: better dev mode comparaison, useful for tests 2026-08-02 09:25:39 +02:00
MasterAcnolo
c484d322bf feat: unit tests for buildArgs and Notify 2026-08-02 09:25:05 +02:00
Axel Nicolas
b321a1d164 Merge pull request #45 from MasterAcnolo/linux-version
1.6.0 - Linux version
2026-07-28 21:38:03 +02:00
MasterAcnolo
89e6fcf9bc fix: snap Store package.json arguments 2026-07-28 20:43:04 +02:00
MasterAcnolo
ba2f317551 fix: snap Store mention in SECURITY.md 2026-07-28 20:42:51 +02:00
MasterAcnolo
553e383c94 refactor: rpm package publish 2026-07-27 22:40:30 +02:00
MasterAcnolo
89cc0b85a3 refactor: rpm package publish 2026-07-27 22:40:22 +02:00
MasterAcnolo
abd0bf9006 refactor: rpm package publish 2026-07-27 22:40:19 +02:00
MasterAcnolo
653f73805e fix: enhance package.json and rename linux package to freedom-loader 2026-07-27 22:15:19 +02:00
Axel Nicolas
4f03c5876f Merge pull request #44 from MasterAcnolo/sync
Sync
2026-07-26 15:15:52 +02:00
MasterAcnolo
a5f05f32af fix: readme missing items and [!TIP] 2026-07-26 11:21:23 +02:00
MasterAcnolo
0d1b1e4919 refactor: security.md 2026-07-26 09:37:39 +02:00
MasterAcnolo
0e77a94ec7 feat: enhance logger by displaying systeminfo at start of the app 2026-07-26 09:37:27 +02:00
MasterAcnolo
7f481d20b2 sync: update dependencies and this is ready for windows 2026-07-25 12:49:36 +02:00
MasterAcnolo
1ff32ca021 sync: to work on windows 2026-07-25 11:32:48 +02:00
MasterAcnolo
90d8a9f65d fix: correct use of logger for path.helpers.js 2026-07-22 20:41:54 +02:00
MasterAcnolo
d5965c2ce0 fix: bring back theme features for Linux 2026-07-22 18:44:30 +02:00
MasterAcnolo
15f59a6093 fix: bring back theme features for Linux 2026-07-22 18:16:33 +02:00
MasterAcnolo
8b31436e25 feat: publish package to fedora copr, add script to automaticaly build and upload the file.
Enhance readme and fix auto updater issue
2026-06-29 10:44:17 +02:00
MasterAcnolo
7d5203001b add: BINARIES.md readme file. About how to install the binaries for dev mode 2026-06-28 21:49:53 +02:00
MasterAcnolo
60709c4fad fix: don't show dialog box if no previous version is available. 2026-06-28 21:48:24 +02:00
MasterAcnolo
2fb6509edc add: dark.zip and white.zip theme to the repo 2026-06-28 21:34:26 +02:00
MasterAcnolo
eebce83976 fix: small code quality and mismatch. 2026-06-28 21:34:00 +02:00
MasterAcnolo
a56a070bc8 feat: support Linux. Refactor resources architecture. Create build script
Currently, themes are disabled and firefox notification too.
2026-06-28 21:32:49 +02:00
MasterAcnolo
e1ef7032bb add: default theme applied in app. If no theme is there 2026-06-18 21:55:50 +02:00
MasterAcnolo
e7333b7b6b fix: missing enableHardwareAcceleration settings in readme 2026-06-10 21:43:38 +02:00
Axel Nicolas
e2b51d32d1 Merge pull request #43 from MasterAcnolo/1.6.0
1.5.1
2026-06-02 22:47:40 +02:00
MasterAcnolo
81515c6ab8 fix: revert version to 1.5.1 in package.json and README.md 2026-06-02 22:35:29 +02:00
MasterAcnolo
c88189b44f feat: add Devtron in dev mode
remove unused settings item
add setting to disable hardware acceleration (enabled by default)
optimize main.js startup loading
2026-06-01 22:39:12 +02:00
MasterAcnolo
b745ebd29f fix: display "dev" in app version if the app is not packaged 2026-05-31 21:45:56 +02:00
MasterAcnolo
13ee4c170b fix: fix width for form 2026-05-31 21:12:35 +02:00
MasterAcnolo
0218664c5a refactor: add full documentation and fix theme loading robustness
- Add JSDoc comments across frontend and backend modules
- Improve code readability and maintainability with consistent documentation style
- Document Electron IPC handlers, UI utilities, and theme system
- Add missing function/class documentation in theme loader and app services
- Minor structural improvements and cleanup across Electron main process modules
- Minor fixes on variable names
2026-05-31 20:38:42 +02:00
MasterAcnolo
6afc5e06d6 fix: app startup. Can't start another instance now. 2026-05-31 15:48:20 +02:00
MasterAcnolo
519dcad6e6 fix: rename localMode => devMode for clarity 2026-05-29 07:42:49 +02:00
MasterAcnolo
ac94d68224 fix: rename localMode => devMode for clarity 2026-05-29 07:42:38 +02:00
MasterAcnolo
0f876fcb70 fix: update dependencies 2026-05-29 07:41:54 +02:00
MasterAcnolo
a6c11ba7c4 update: version for 1.6.0 2026-05-15 19:57:33 +02:00
MasterAcnolo
44132dd7b5 fix: update dependencies 2026-05-11 21:14:00 +02:00
MasterAcnolo
15ab6892b7 Fix: remove duplication roadmap (now full in website) 2026-05-01 12:07:39 +02:00
MasterAcnolo
725b6f0ada Fix: Configuration File Location 2026-04-15 13:55:13 +02:00
MasterAcnolo
ad0e4f858b Fix: missing options in readme 2026-04-15 13:50:39 +02:00
MasterAcnolo
68108e3a66 Update Workshop link to GitHub repository 2026-04-14 11:28:53 +02:00
62 changed files with 8551 additions and 2279 deletions

27
.gitignore vendored
View File

@@ -1,15 +1,24 @@
# Dependencies
/node_modules
/downloads
# Build Output Folder
/dist
/out
/srpm-out
/ressources/ffmpeg.exe
/ressources/ffprobe.exe
/ressources/deno.exe
/theme/**
# Config JSON File. Create it and fill it if you want to have dev only settings
config/config.dev.json
logs/*.json
logs/*.log
# IDE
/.idea
/.vscode
# Linux executable
ffmpeg
ffprobe
deno
# Windows Executable
ffmpeg.exe
ffprobe.exe
deno.exe

96
BINARIES.md Normal file
View File

@@ -0,0 +1,96 @@
# External binaries
This project depends on three external tools that are **not bundled in the repository**.
You must download them manually and place each binary in the correct folder before building or running the app locally.
---
## Required tools
| Tool | Role |
|---------|---------------------------------------------|
| yt-dlp | Video and audio download engine |
| ffmpeg | Media encoding, transcoding, muxing |
| ffprobe | Media file analysis and metadata extraction |
| deno | JavaScript / TypeScript runtime |
---
## Folder structure
Place the binaries exactly as shown below.
Do not rename them — the application resolves them by these exact names.
```
resources/
└── binaries/
├── linux/
│ ├── yt-dlp
│ ├── ffmpeg
│ ├── ffprobe
│ └── deno
└── win-32/
├── yt-dlp.exe
├── ffmpeg.exe
├── ffprobe.exe
└── deno.exe
```
---
## Download links
### yt-dlp
| Platform | URL |
|----------|----------------------------------------------------------------------|
| Windows | https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe |
| Linux | https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp |
Rename the downloaded file to `yt-dlp` (or `yt-dlp.exe` on Windows).
---
### ffmpeg and ffprobe
**Windows and Linux** — both binaries are included in the same archive.
| Platform | URL |
|-------------|-----------------------------------------------------------------------------------------------------------|
| Windows x64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip |
| Linux x64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz |
| Linux arm64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz |
Extract the archive and copy `ffmpeg` and `ffprobe` from the `bin/` subfolder.
---
### deno
| Platform | URL |
|-------------|----------------------------------------------------------------------------------------------|
| Windows x64 | https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip |
| Linux x64 | https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip |
| Linux arm64 | https://github.com/denoland/deno/releases/latest/download/deno-aarch64-unknown-linux-gnu.zip |
Each archive contains a single binary. Rename it to `deno` (or `deno.exe` on Windows).
---
## Permissions (Linux )
After placing the binaries, make them executable:
```bash
chmod +x resources/binaries/linux/yt-dlp
chmod +x resources/binaries/linux/ffmpeg
chmod +x resources/binaries/linux/ffprobe
chmod +x resources/binaries/linux/deno
```
---
## Notes
- These binaries are excluded from version control via `.gitignore`.
- Always use the latest stable release of each tool.

View File

@@ -105,7 +105,7 @@ Freedom-Loader/
│ ├── controller/ # Business logic
│ └── helpers/ # Utility functions
├── public/ # Frontend (HTML, CSS, JS)
└── ressources/ # Binaries (yt-dlp, ffmpeg, etc.)
└── resources/ # Binaries (yt-dlp, ffmpeg, etc.)
```
---

239
README.md
View File

@@ -5,9 +5,9 @@
alt="Banner"
style="width: 50%;"/></a>
### **A clean, open-source multimedia downloader for Windows**
### **A clean, open-source multimedia downloader for Windows and Linux**
[![Release](https://img.shields.io/badge/Release-1.5.0-blue?style=for-the-badge)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![Release](https://img.shields.io/github/v/release/MasterAcnolo/Freedom-Loader?style=for-the-badge&color=blue)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0)
[![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/)
[![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
@@ -26,7 +26,7 @@ The primary goal is to make media downloading accessible to users who want offli
### See related repo:
- **[Website](https://masteracnolo.github.io/Freedom-Loader-Site/)** - Official project website and documentation
- **[Workshop](https://masteracnolo.github.io/Freedom-Loader-Workshop/)** - Theme creation tool and theme browser
- **[Workshop](https://github.com/MasterAcnolo/Freedom-Loader-Workshop)** - Theme creation tool and theme browser
## Table of Contents
@@ -61,9 +61,9 @@ The primary goal is to make media downloading accessible to users who want offli
### User Interface
- **Custom window controls** - Frameless window with custom top bar (optional)
- **Custom themes** — ZIP-based theme system with live preview
- **Theme Workshop** — web-based theme creator and browser
- **Toast notifications** — in-app feedback system
- **Custom themes** - ZIP-based theme system with live preview
- **Theme Workshop** - web-based theme creator and browser
- **Toast notifications** - in-app feedback system
- **Real-time progress tracking** - Live download progress bar with network speed indicator
- **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming
- **Settings panel** - In-app configuration interface with live updates
@@ -80,41 +80,89 @@ The primary goal is to make media downloading accessible to users who want offli
- **Rate limiting** - Express-based request throttling to prevent server overload
- **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections
- **System notifications** - Windows notifications on download completion (clickable to open folder)
- **Splash screen** — animated loading screen on startup
- **Stop download** — ability to cancel in-progress downloads
- **Playlist folders** — dedicated folder creation per playlist
- **Splash screen** - animated loading screen on startup
- **Stop download** - ability to cancel in-progress downloads
- **Playlist folders** - dedicated folder creation per playlist
- **Keep Playlist Order** - Add an Index before the title, could be useful for an album for example.
## Installation
[![Fedora Copr](https://img.shields.io/badge/Fedora-Copr-294172?style=for-the-badge&logo=fedora&logoColor=white)](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
[![Debian/Ubuntu](https://img.shields.io/badge/.deb-Debian%2FUbuntu-A81D33?style=for-the-badge&logo=debian&logoColor=white)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![Snap Store](https://img.shields.io/badge/Snap-Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white)](https://snapcraft.io/freedom-loader)
[![AppImage](https://img.shields.io/badge/AppImage-Universal-1E88E5?style=for-the-badge&logo=linux&logoColor=white)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
### Requirements
- **Operating System**: Windows 10 or later
- **Browser**: Mozilla Firefox (required for cookie extraction)
- [Download Firefox](https://www.firefox.com)
- **Windows**: Windows 10 or later
- **Linux**: any modern x86_64 distribution
- **Browser**: Mozilla Firefox ([download](https://www.firefox.com)) required for cookie extraction, on both platforms
### Standard Installation
### Windows
1. Download the latest release from the [Releases page](https://github.com/MasterAcnolo/Freedom-Loader/releases)
2. Run the downloaded `.exe` installer
3. If Windows Defender shows a warning, click "More info" then "Run anyway"
4. Follow the installation wizard
1. Download the latest `.exe` from the [Releases page](https://github.com/MasterAcnolo/Freedom-Loader/releases)
2. Run the installer (click "More info" → "Run anyway" if Windows Defender warns you)
3. Follow the installation wizard
### Linux
#### Fedora (via Copr - recommended)
Installing via Copr lets `dnf` handle future updates automatically:
```bash
sudo dnf copr enable masteracnolo/freedom-loader
sudo dnf install freedom-loader
```
#### Debian / Ubuntu (.deb)
```bash
# Download the latest .deb from the Releases page, then:
sudo apt install ./freedom-loader_1.x.x_amd64.deb
```
#### Snap (any distro with snapd)
```bash
sudo snap install freedom-loader
```
#### RPM (Fedora/RHEL/openSUSE, without Copr)
```bash
# Download the latest .rpm from the Releases page, then:
sudo dnf install ./freedom-loader-1.x.y.x86_64.rpm
```
#### AppImage (any distro, no installation needed)
```bash
# Download the latest .AppImage from the Releases page, then:
chmod +x Freedom-Loader-1.x.y.AppImage
./Freedom-Loader-1.x.y.AppImage
```
Once installed via any method, launch Freedom Loader from your application menu, or from a terminal with:
```bash
freedom-loader
```
### File Locations
Downloaded files are stored in:
```
C:\Users\[USERNAME]\Downloads\Freedom Loader
```
> [!NOTE]
> *You can change it whenever you want. Just click the "Edit" button in the UI.*
| | Windows | Linux |
|---------------|---------------------------------------------------------|--------------------------------------|
| **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
| **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
Application logs can be found at:
```
C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\
```
> [!NOTE]
> The download folder can be changed anytime via the "Edit" button in the UI, on both platforms.
## Usage
> [!TIP]
> On Linux, you can start Freedom Loader using `freedom-loader` command
### Basic Download
1. Launch Freedom Loader
@@ -131,14 +179,15 @@ The application supports various configuration options:
- Custom download directory selection
- Discord Rich Presence activation
- Theme customization
- Log level configuration
- Log level
- And somes to see in the list [Configuration Options](#configuration-options) available
### Troubleshooting
If you encounter issues:
1. Restart the application
2. Check the logs in `AppData\Local\FreedomLoader\logs\`
2. Check the logs in `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs`
3. Open the developer console: `CTRL + SHIFT + I` → "Toggle Developer Tools"
4. Create an issue on GitHub with logs and reproduction steps
@@ -164,8 +213,15 @@ 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.
### Configuration File Location
#### Windows
```
C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
%APPDATA%\Freedom Loader\config.json
```
#### Linux
```
~/.config/Freedom\ Loader/config.json
```
### Available Options
@@ -179,31 +235,39 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
"addMetadata": true,
"verboseLogs": false,
"autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true,
"customCodec": "h264",
"logSystem": true,
"outputTitleCheck": true,
"downloadSystem": true,
"notifySystem": true
"notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark"
}
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
| `customTopBar` | boolean | `true` | Use custom application top bar |
| `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging |
| `outputTitleCheck` | boolean | `true` | Verify output file titles |
| `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications |
| Option | Type | Default | Description |
|------------------------------|---------|----------|------------------------------------------------------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates |
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
| `customTopBar` | boolean | `true` | Use custom application top bar |
| `autoCheckInfo` | boolean | `true` | Automatically fetch video information on URL paste |
| `addThumbnail` | boolean | `true` | Embed thumbnail in downloaded audio files |
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `keepPlaylistOrder` | boolean | `false` | Add index before title, usefull for album |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging |
| `outputTitleCheck` | boolean | `true` | Verify output file titles |
| `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications |
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
| `theme` | string | `dark` | Current Theme Name |
### Supported Codecs
@@ -216,7 +280,7 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
- `theora` - Theora (legacy open codec)
> [!NOTE]
> Configuration changes may require an application restart to take effect.
> Some configuration changes may require an application restart to take effect.
## Project Structure
@@ -233,12 +297,17 @@ Freedom-Loader/
│ ├── windowManager.js
│ └── ytDlpUpdater.js
├── build/ # Build resources and assets
│ ├── app-icon.ico
│ ├── app-icon.png
│ ├── banner.png
│ └── ...
├── config/ # Configuration files
│ ├── config.default.json
│ └── config.dev.json
│ └── config.dev.json # Configuration Used when i dev mode
├── dist/ # Compiled executables (.exe, .AppImage, .deb, .rpm, .snap)
├── public/ # Frontend assets and UI
│ ├── index.html
│ ├── splash.html
│ ├── splash.html # Splash Screen (Start of the application)
│ ├── assets/
│ │ ├── icon/ # Application icons
│ │ └── logo/ # Logo assets
@@ -257,19 +326,8 @@ Freedom-Loader/
│ ├── styles.css
│ ├── variables.css
│ ├── components/ # Component-specific styles
│ │ ├── checkbox.css
│ │ ├── editpathbutton.css
│ │ ├── loader.css
│ │ ├── progressBar.css
│ │ └── toast.css
│ └── layout/ # Layout styles
│ ├── container.css
│ ├── form.css
│ ├── header.css
│ ├── settingsPanel.css
│ ├── topbar.css
│ └── videoinfo.css
├── ressources/ # Internal resources (binaries)
├── resources/ # Internal resources (binaries : yt-dlp, ffmpeg...)
├── server/ # Express backend server
│ ├── logger.js
│ ├── server.js
@@ -293,16 +351,16 @@ Freedom-Loader/
├── theme/ # Theme system
│ ├── template.theme.json # Theme template
│ ├── Dark/ # Default dark theme
│ │ └── dark.theme.json
│ └── Light/ # Default light theme
│ └── light.theme.json
├── main.js # Electron main process entry point
├── preload.js # Electron preload script (IPC bridge)
├── config.js # Global configuration loader
├── package.json # Project metadata and dependencies
├── BINARIES.md # External dependencies & binaries documentation
├── CODE_OF_CONDUCT.md # Community guidelines
├── config.js # Global configuration loader
├── CONTRIBUTING.md # Contribution guidelines
├── jest.config.js # Unit testing configuration
├── LICENSE # GPLv3 license
├── main.js # Electron main process entry point
├── package.json # Project metadata and dependencies
├── preload.js # Electron preload script (IPC bridge)
└── README.md # This file
```
@@ -329,7 +387,7 @@ Freedom Loader includes a web-based theme creator available at [Freedom Loader W
### Installing a custom theme
1. Download a `.zip` theme file
2. Drop it in the `theme/` folder of your Freedom Loader installation
3. Restart the app — your theme appears in the settings panel
3. Restart the app - your theme appears in the settings panel
## Technology Stack
@@ -380,7 +438,7 @@ npm run build
This project relies on several external binaries.
Some of them are **not included** in the public repository and must be added manually.
You must download the required binaries and place them in the `ressources` folder, using the exact filenames listed below.
You must download the required binaries and place them in the `resources` folder, using the exact filenames listed below.
#### Required binaries
@@ -401,7 +459,7 @@ You must download the required binaries and place them in the `ressources` folde
Final folder structure:
```
ressources/
resources/
├── deno.exe
├── ffmpeg.exe
├── ffprobe.exe
@@ -417,45 +475,12 @@ ressources/
- Write clear commit messages
- Test thoroughly before submitting changes
- Update documentation when adding features
- Maintain compatibility with Windows 10+
- Maintain compatibility with Windows 10+ and Linux
## Roadmap
See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap).
### Completed
- [x] ~~Website and documentation~~
- [x] ~~Automatic YT-DLP updates~~
- [x] ~~Automatic application updates~~
- [x] ~~Enhanced download status display~~
- [x] ~~Video chapter extraction~~
- [x] ~~Custom output path selection~~
- [x] ~~Custom codec selection~~
- [x] ~~Settings Panel with toggle switch for features.~~
- [x] ~~Custom theme system (ZIP format) ~~
- [x] ~~Theme Workshop (web tool)~~
- [x] ~~Splash screen~~
- [x] ~~Download stop button~~
- [x] ~~Config saved in AppData (preserved on update)~~
### In Progress
- [ ] Additional format support (WebM, OGG, etc.)
- [ ] Chrome/Edge cookie support
- [ ] Improved multi-site support
### Planned
- [ ] Subtitle download and embedding
- [ ] Multiple language support
- [ ] Partial video downloads
- [ ] Custom file naming patterns
- [ ] Parallel downloads
- [ ] Automatic sponsor detection (SponsorBlock integration)
- [ ] Download history
- [ ] Linux version
## Contributing
Contributions are welcome and appreciated. This project benefits from community involvement.
@@ -472,7 +497,7 @@ Use the GitHub Issues system and include:
- Clear description of the issue
- Steps to reproduce
- Expected vs actual behavior
- Relevant logs from `AppData\Local\FreedomLoader\logs\`
- Relevant logs from `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs/`
- Screenshots if applicable
### Feature Requests
@@ -512,7 +537,7 @@ You are free to use, modify, and redistribute this software under the terms of t
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) for the robust download engine
- [FFmpeg](https://ffmpeg.org/) for media processing capabilities
- [Deno](https://deno.com/) for helping me bypassing challenges
- [Deno](https://deno.com/) for helping me to bypass challenges
- [Electron](https://www.electronjs.org/) for the desktop application framework
- The open-source community for continuous support and contributions
- [@SpicyFire21](https://github.com/SpicyFire21) to be the spiciest one
@@ -528,4 +553,4 @@ You are free to use, modify, and redistribute this software under the terms of t
[Website](https://masteracnolo.github.io/Freedom-Loader-Site/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues)
</div>
</div>

View File

@@ -2,15 +2,14 @@
## Supported Versions
We actively maintain and provide security updates for the following versions of Freedom Loader:
Freedom Loader follows a **latest-only** support model. Security fixes are applied to the current release only — no backports to older versions.
| Version | Supported |
| ------- | ------------------ |
| 1.4.x | :white_check_mark: |
| 1.3.x | :white_check_mark: |
| < 1.3.0 | :x: |
| Version | Supported |
| ------------- | ------------------ |
| Latest stable | :white_check_mark: |
| Older versions | :x: |
**Note**: We recommend always using the latest version to benefit from the most recent security patches and features.
I recommend always running the latest version. The built-in auto-updater will notify you when a new release is available. Older versions will not receive security patches regardless of severity.
## Reporting a Vulnerability
@@ -32,37 +31,40 @@ Instead, please report security issues privately by:
### What to Expect
- **Acknowledgment**: We will acknowledge receipt of your report within 48 hours
- **Assessment**: We will assess the vulnerability and determine its severity
- **Updates**: We will keep you informed of our progress
- **Resolution**: We aim to release a fix within 7-14 days for critical vulnerabilities
- **Credit**: We will credit you in the release notes (unless you prefer to remain anonymous)
- **Acknowledgment**: I will acknowledge receipt of your report within 48 hours
- **Assessment**: I will assess the vulnerability and determine its severity
- **Updates**: I will keep you informed of our progress
- **Resolution**: I aim to release a fix within 7-14 days for critical vulnerabilities
- **Credit**: I will credit you in the release notes (unless you prefer to remain anonymous)
## Security Best Practices for Users
When using Freedom Loader, we recommend:
When using Freedom Loader, I recommend:
### General Security
- **Keep Updated**: Always use the latest version of Freedom Loader
- **Official Sources**: Download only from official releases on [GitHub](https://github.com/MasterAcnolo/Freedom-Loader/releases)
- **Verify Downloads**: Check that installers are properly signed (Windows SmartScreen may show warnings for new releases)
- **Keep Updated**: Always use the latest version of Freedom Loader — older versions receive no security patches
- **Official Sources**: Download only from official sources:
- [GitHub Releases](https://github.com/MasterAcnolo/Freedom-Loader/releases)
- [Fedora COPR](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
- [Snap Store](https://snapcraft.io/freedom-loader)
- **Verify Downloads**: Check that installers are from one of the above sources
- **Antivirus**: Keep your antivirus software up to date
### Configuration Security
- **Download Path**: Only set download paths within your user directory (`C:\Users\[USERNAME]\...`)
- **Cookies**: Be aware that Firefox cookies are used for authentication—keep Firefox secure
- **Logs**: Logs may contain sensitive information—avoid sharing them publicly without review
- **Download Path**: Only set download paths within your user directory
- **Cookies**: Be aware that Firefox cookies are used for authentication — keep Firefox secure
- **Logs**: Logs may contain sensitive information such as URLs or local paths — avoid sharing them publicly without review
### Privacy Considerations
Freedom Loader respects your privacy:
- **No Data Collection**: We don't collect, store, or transmit your personal data
- **No Data Collection**: I don't collect, store, or transmit your personal data
- **No Telemetry**: No usage tracking or analytics
- **Local Operation**: All downloads are processed locally on your machine
- **Optional Features**: Discord RPC is optional and can be disabled
- **Optional Features**: Discord RPC is optional and can be disabled in settings
## Known Security Considerations
@@ -71,16 +73,16 @@ Freedom Loader respects your privacy:
Freedom Loader accesses Firefox cookies to download protected content. This is:
- **By Design**: Required for age-restricted or member-only content
- **Local Only**: Cookies are read locally and never transmitted
- **User Controlled**: You can control what content you download
- **Local Only**: Cookies are read locally and never transmitted outside your machine
- **User Controlled**: You control what content you download
### Native Dependencies
Freedom Loader bundles native binaries:
Freedom Loader bundles the following official binaries:
- **yt-dlp**: Official builds from [yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp)
- **yt-dlp**: Official builds from [yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp) — auto-updated on each launch
- **FFmpeg**: Official builds from [FFmpeg.org](https://ffmpeg.org/)
- **Deno**: Official builds from [Deno.land](https://deno.land/)
- **Deno**: Official builds from [Deno.com](https://deno.com/)
These dependencies are verified and updated regularly.
@@ -91,13 +93,13 @@ New releases may trigger Windows Defender warnings because:
- The application is not yet widely distributed
- Code signing certificates are expensive for open-source projects
This is expected behavior for new releases. The warning will decrease as more users download the software.
This is expected behavior for new releases and will diminish as more users download the software.
## Vulnerability Disclosure Policy
### Our Commitment
We are committed to:
I am committed to:
- Responding promptly to security reports
- Working with security researchers to verify and address issues
@@ -107,9 +109,9 @@ We are committed to:
### Disclosure Timeline
1. **Day 0**: Vulnerability reported privately
2. **Day 1-2**: Acknowledgment sent to reporter
3. **Day 3-7**: Vulnerability assessed and fix developed
4. **Day 7-14**: Fix released (critical vulnerabilities prioritized)
2. **Day 1–2**: Acknowledgment sent to reporter
3. **Day 3–7**: Vulnerability assessed and fix developed
4. **Day 7–14**: Fix released (critical vulnerabilities prioritized)
5. **Day 14+**: Public disclosure (coordinated with reporter)
### Scope
@@ -129,16 +131,16 @@ The following are generally not considered security vulnerabilities:
- Issues requiring physical access to the user's machine
- Social engineering attacks
- Denial of service against third-party services
- Issues in third-party dependencies (report to upstream projects)
- Missing security headers on local HTTP server (no remote access)
- Issues in third-party dependencies (report to upstream projects directly)
- Missing security headers on the local HTTP server (not exposed remotely)
## Security Updates
Security updates are released as:
- **Patch Releases**: For critical security fixes (e.g., 1.4.1 → 1.4.2)
- **Patch Releases**: For critical security fixes (e.g., 1.6.0 → 1.6.1)
- **Automatic Updates**: Users are notified via the built-in update system
- **Release Notes**: Security fixes are clearly marked in changelog
- **Release Notes**: Security fixes are clearly marked in the changelog
## Additional Resources
@@ -150,6 +152,7 @@ Security updates are released as:
For security-related questions or concerns:
- **Email**: masteracnolo25@gmail.com
- **GitHub**: [@MasterAcnolo](https://github.com/MasterAcnolo)
---

View File

@@ -1,11 +1,42 @@
/**
* Electron auto-update system using electron-updater.
*
* Handles:
* - update detection
* - user confirmation dialogs
* - download progress streaming to renderer
* - application restart and installation
*/
const { autoUpdater } = require("electron-updater");
const { dialog } = require("electron");
const { logger } = require("../server/logger");
/**
* Configures autoUpdater behavior:
* - disables automatic download
* - disables automatic installation on quit
* (manual user-controlled update flow)
*/
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
/**
* Initializes application auto-update lifecycle.
*
* Registers update event listeners and binds them to:
* - UI dialogs (Electron dialog)
* - renderer communication (webContents)
* - download/install flow control
*
* @param {BrowserWindow} mainWindow - main Electron window instance
*/
function initAutoUpdater(mainWindow) {
/**
* Triggered when a new version is detected.
* Prompts user to install or defer update.
*/
autoUpdater.on("update-available", async (info) => {
logger.info(`Update available: ${info.version}`);
@@ -20,7 +51,7 @@ function initAutoUpdater(mainWindow) {
});
if (response === 0) {
autoUpdater.downloadUpdate();
await autoUpdater.downloadUpdate();
} else {
mainWindow?.webContents.executeJavaScript(
`window.showUpdateBadge && window.showUpdateBadge("${info.version}")`
@@ -28,6 +59,10 @@ function initAutoUpdater(mainWindow) {
}
});
/**
* Streams update download progress to renderer process.
* Used to update UI progress bar and status indicators.
*/
autoUpdater.on("download-progress", (progress) => {
logger.info(`Download progress: ${Math.round(progress.percent)}%`);
mainWindow?.webContents.send("update-progress", {
@@ -36,6 +71,10 @@ function initAutoUpdater(mainWindow) {
});
});
/**
* Triggered when update has been fully downloaded.
* Prompts user to restart application and install update.
*/
autoUpdater.on("update-downloaded", async (info) => {
logger.info(`Update downloaded: ${info.version}`);
@@ -52,15 +91,43 @@ function initAutoUpdater(mainWindow) {
if (response === 0) autoUpdater.quitAndInstall();
});
/**
* Handles update system errors.
* Logs failure and displays an error dialog to user.
*/
autoUpdater.on("error", (err) => {
logger.error("Auto update error:", err.message);
dialog.showErrorBox("Update Error", err.message);
const msg = err?.message || "";
/**
* If no update is available, I put this because there is no Linux version before 1.6.0
* @type {boolean}
*/
const isNoUpdateAvailable = /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg);
if (isNoUpdateAvailable) {
logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg);
return;
}
logger.error("Auto update error:", msg);
});
checkForUpdates();
}
/**
* Manually triggers update check on startup.
* Separated from init for reusability and testability.
*/
async function checkForUpdates() {
if (process.env.SNAP) {
logger.info("Running as Snap, update managed by snapd, skipping electron-updater");
return;
}
if (!require("electron").app.isPackaged) return ;
try {
await autoUpdater.checkForUpdates();
} catch (err) {
@@ -68,6 +135,10 @@ async function checkForUpdates() {
}
}
/**
* Manually triggers update download.
* Used when user accepts update prompt.
*/
async function downloadUpdate() {
try {
await autoUpdater.downloadUpdate();
@@ -76,6 +147,9 @@ async function downloadUpdate() {
}
}
/**
* Immediately quits application and installs downloaded update.
*/
function installUpdate() {
autoUpdater.quitAndInstall();
}

View File

@@ -2,26 +2,61 @@ const fs = require("fs");
const { app, dialog } = require("electron");
const { logger } = require("../server/logger");
/**
* Verifies that all required native binaries exist before the app starts.
*
* This includes:
* - yt-dlp (download engine)
* - ffmpeg (media processing)
* - ffprobe (media inspection)
* - deno (JS runtime used by yt-dlp hooks)
*
* If any dependency is missing:
* - logs the error
* - shows a blocking Electron error dialog
* - exits the application
*
* @returns {boolean} true if all dependencies are present, false otherwise
*/
function checkNativeDependencies() {
/**
* Native dependency paths are resolved at runtime from the server helpers.
* This ensures correct resolution in both dev and packaged builds.
*/
const { binaryPaths } = require("../server/helpers/path.helpers");
const isWindows = process.platform === "win32";
const addExe = isWindows ? ".exe" : "";
/**
* List of required external executables used by the application runtime.
* Each entry defines a binary name and its resolved absolute path.
*/
const deps = [
{ name: "yt-dlp.exe", path: binaryPaths.ytDlp },
{ name: "ffmpeg.exe", path: binaryPaths.ffmpeg },
{ name: "ffprobe.exe", path: binaryPaths.ffprobe },
{ name: "deno.exe", path: binaryPaths.deno },
{ name: `yt-dlp${addExe}`, path: binaryPaths.ytDlp },
{ name: `ffmpeg${addExe}`, path: binaryPaths.ffmpeg },
{ name: `ffprobe${addExe}`, path: binaryPaths.ffprobe },
{ name: `deno${addExe}`, path: binaryPaths.deno },
];
const missing = deps.filter(d => !fs.existsSync(d.path));
if (missing.length === 0) return true;
/**
* Human-readable list of missing binaries for logging and user dialog display.
*/
const list = missing.map(d => d.name).join(", ");
logger.error(`Missing dependencies: ${list}`);
/**
* Defers error dialog until Electron app is fully initialized.
* Required to safely display modal dialogs before quit.
*/
app.whenReady().then(() => {
dialog.showErrorBox(
"Missing dependencies",
`The following files are missing in the 'ressources' folder:\n${list}\n\nThe application will now exit. Try to reinstall.`
`The following files are missing in the 'resources' folder:\n${list}\n\nThe application will now exit. Try to reinstall.`
);
app.quit();
});

View File

@@ -1,13 +1,93 @@
const config = require('../config');
const RPC = require("discord-rpc");
const { logger } = require("../server/logger");
const fs = require("fs");
const path = require("path");
/**
* Discord Application Client ID used to authenticate the RPC connection.
* Comes from the application configuration.
*/
const clientId = `${config.DiscordRPCID}`;
/**
* Discord RPC client instance using IPC transport.
* Maintains a persistent connection with Discord desktop client.
*/
const rpc = new RPC.Client({ transport: "ipc" });
/**
* Optional interval reference used for future RPC refresh logic.
* Currently reserved for potential periodic activity updates.
*/
let intervalId;
/**
* Automagically fixes Discord RPC on Linux for Sandboxed versions (Flatpak & Snap).
* It creates a symlink from the sandbox IPC socket to the standard system location.
*
* @returns {void}
*/
function fixLinuxDiscordRPC() {
// Only execute on Linux
if (process.platform !== "linux") return;
/**
* Get the user's runtime directory (e.g., /run/user/1000).
* @type {string}
*/
const xdgDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid()}`;
const standardSocket = path.join(xdgDir, "discord-ipc-0");
// If the standard socket already exists (Native Discord), do nothing.
if (fs.existsSync(standardSocket)) return;
/**
* Paths for known sandboxed Discord IPC sockets.
*/
const flatpakSocket = path.join(xdgDir, "app/com.discordapp.Discord/discord-ipc-0");
const snapSocket = path.join(xdgDir, "snap.discord/discord-ipc-0");
let targetSocket = null;
if (fs.existsSync(flatpakSocket)) {
targetSocket = flatpakSocket;
} else if (fs.existsSync(snapSocket)) {
targetSocket = snapSocket;
}
// If a sandboxed socket is found, create the symlink stealthily.
if (targetSocket) {
try {
fs.symlinkSync(targetSocket, standardSocket);
logger.info(`Discord RPC bridge created automatically targeting: ${targetSocket}`);
} catch (err) {
logger.warn(`Failed to create Discord RPC bridge: ${err.message}`);
}
}
}
/**
* Initializes Discord Rich Presence (RPC) connection.
*
* Responsibilities:
* - Connect to Discord via IPC transport
* - Set application presence (title, state, timestamps, assets)
* - Handle connection errors gracefully via logger
*
* Triggered once during application startup.
*/
function startRPC() {
/**
* Patch that create Simlinks for Linux environment;
*/
fixLinuxDiscordRPC();
/**
* Rich Presence payload describing current application state
* shown in Discord user profile.
*/
rpc.on("ready", () => {
const presence = {
largeImageKey: "icon",
@@ -27,21 +107,35 @@ function startRPC() {
});
}
async function stopRPC(){
/**
* Gracefully stops Discord Rich Presence connection.
*
* Responsibilities:
* - Clear any running intervals
* - Remove current activity from Discord if connected
* - Destroy RPC transport connection
* - Handle cleanup errors safely
*/
async function stopRPC() {
if (rpc) {
try {
if (intervalId) clearInterval(intervalId);
if (rpc && rpc.transport) {
await rpc.clearActivity()
await rpc.destroy();
} else{
logger.error("Not Able to close RPC")
/**
* Ensures RPC connection AND the underlying socket exist
* before attempting to clear activity to prevent crash.
*/
if (rpc.transport && rpc.transport.socket) {
await rpc.clearActivity();
}
await rpc.destroy();
} catch (err) {
logger.error("Error while closing the RPC:", err);
}
}
}
module.exports = { startRPC, stopRPC};

View File

@@ -1,6 +1,17 @@
/**
* Electron IPC main process registry.
*
* Centralizes all IPC channels exposed to renderer process:
* - Application info (version, features)
* - Window controls (topbar actions)
* - File system interactions (theme, config, download paths)
* - UI state synchronization (progress bar)
*
* Acts as the bridge between renderer and privileged Node/Electron APIs.
*/
const { ipcMain, dialog, shell } = require("electron");
const fs = require("fs");
const path = require("path");
const { logger, logDir } = require("../server/logger");
const { configFeatures, featuresPath } = require("../config");
const { getThemes, reloadThemes } = require("./themeManager");
@@ -8,6 +19,12 @@ const config = require("../config");
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
const { userThemesPath } = require("../server/helpers/path.helpers");
/**
* Security whitelist for feature flags that can be modified at runtime.
*
* Prevents unauthorized or unexpected configuration keys from being persisted.
* Acts as a basic validation layer for IPC "set-feature".
*/
const FEATURE_WHITELIST = new Set([
"autoUpdate",
"discordRPC",
@@ -17,23 +34,53 @@ const FEATURE_WHITELIST = new Set([
"addMetadata",
"verboseLogs",
"autoDownloadPlaylist",
"keepPlaylistOrder",
"customCodec",
"theme",
"createPlaylistFolders",
"notifySystem"
"notifySystem",
"enableHardwareAcceleration"
]);
/**
* Absolute path to the configuration file storing feature flags.
*/
const configFolderPath = featuresPath;
/**
* Directory containing user-installed themes.
*/
const themeFolderPath = userThemesPath;
/**
* Registers all IPC handlers and event listeners for Electron main process.
*
* This function wires renderer → main communication channels:
* - synchronous queries (handle)
* - fire-and-forget events (on)
*
* @param {Function} getMainWindow - Function returning current BrowserWindow instance
*/
function registerIpcHandlers(getMainWindow) {
// Infos générales
ipcMain.handle("version", () => config.version);
/**
* Returns application version from config.
*/
ipcMain.handle("version", () => config.version);
/**
* Returns runtime feature configuration object.
*/
ipcMain.handle("features", () => configFeatures);
// Sélection et validation de dossier
/**
* Opens native folder selection dialog and validates selected path.
*
* Ensures:
* - user cancellation is handled safely
* - selected path is validated against security rules
* - unsafe directories are rejected
*/
ipcMain.handle("select-download-folder", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (result.canceled) {
@@ -53,48 +100,108 @@ function registerIpcHandlers(getMainWindow) {
ipcMain.handle("validate-download-path", (_, userPath) => validateDownloadPath(userPath));
ipcMain.handle("get-default-download-path", () => getDefaultDownloadPath());
// Progression dans la taskbar
/**
* Updates Windows/macOS taskbar progress indicator.
*
* @param {number} percent - Progress value (0–100)
*/
ipcMain.on("set-progress", (_, percent) => {
getMainWindow()?.setProgressBar(percent / 100);
});
// TOPBAR ACTION
/**
* Window minimize request from renderer.
*/
ipcMain.on("window-minimize", () => getMainWindow()?.minimize());
/**
* Toggles maximize/unmaximize state of main window.
*/
ipcMain.on("window-maximize", () => {
const win = getMainWindow();
if (!win) return;
win.isMaximized() ? win.unmaximize() : win.maximize();
});
/**
* Closes the main application window.
*/
ipcMain.on("window-close", () => getMainWindow()?.close());
/**
* Opens Chromium DevTools in detached mode.
*/
ipcMain.on("open-devtools", () =>
getMainWindow()?.webContents.openDevTools({ mode: "detach" })
);
/**
* Opens application logs directory in system file explorer.
*/
ipcMain.on("open-logs", () => logDir && shell.openPath(logDir));
/**
* Opens external website in default browser.
*/
ipcMain.on("open-website", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/")
);
/**
* Opens official wiki page in external browser.
*/
ipcMain.on("open-wiki", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki")
);
/**
* Opens workshop page in external browser.
*/
ipcMain.on("open-workshop", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Workshop")
);
/**
* Opens configuration folder in system file explorer.
*/
ipcMain.on("open-config", () => shell.openPath(configFolderPath));
// THEME
/**
* Retrieves available themes from filesystem.
*/
ipcMain.handle("get-themes", () => getThemes());
/**
* Opens theme directory in file explorer.
*/
ipcMain.on("open-theme", () => shell.openPath(themeFolderPath));
/**
* Reloads themes from disk dynamically.
*/
ipcMain.handle("reload-themes", async () => {
return await reloadThemes();
});
// Modification des features
/**
* Updates a runtime feature flag and persists it to disk.
*
* Flow:
* - Validates key against whitelist
* - Prevents unnecessary writes if value is unchanged
* - Updates in-memory config object
* - Persists to configuration file
*
* Security note:
* Only whitelisted keys can be modified via IPC to prevent config injection.
*
* @param {Electron.IpcMainEvent} event
* @param {{key: string, value: any}} payload
* @returns {boolean} success state
*/
ipcMain.handle("set-feature", (event, { key, value }) => {
try {
if (!FEATURE_WHITELIST.has(key)) {

View File

@@ -2,28 +2,86 @@ const fs = require("fs");
const path = require("path");
const { logger } = require("../server/logger");
/**
* Cached resolved default download path.
* Initialized lazily on first access to avoid unnecessary imports.
*/
let _defaultPath = null;
/**
* Returns the application's default download directory.
*
* This value is lazily loaded and cached after first call.
* The path originates from server configuration helpers.
*
* @returns {string} Absolute default download path
*/
function getDefaultDownloadPath() {
if (!_defaultPath) {
/**
* Lazy-loaded dependency providing the configured default download folder.
* Required only when function is executed to reduce startup cost.
*/
const { defaultDownloadFolder } = require("../server/helpers/path.helpers");
_defaultPath = defaultDownloadFolder;
}
return _defaultPath;
}
/**
* Validates and sanitizes a user-provided download path.
*
* Steps performed:
* - Resolves absolute real filesystem path
* - Normalizes symbolic links via realpathSync
* - Checks path safety rules (blocks system-critical directories)
* - Falls back to default path if input is empty
*
* @param {string} userPath - User-provided directory path
* @returns {string} Sanitized absolute path
* @throws Error If path is invalid, unsafe, or inaccessible
*/
function validateDownloadPath(userPath) {
/**
* Lazy-loaded helper used to enforce safe directory constraints.
* Prevents writes to protected system folders.
*/
const { isSafePath } = require("../server/helpers/validation.helpers");
if (!userPath) return getDefaultDownloadPath();
try {
/**
* Resolves filesystem symlinks to ensure canonical absolute path.
* Required to prevent path traversal or alias bypass.
*/
const resolved = fs.realpathSync(path.resolve(userPath));
if (!fs.existsSync(resolved)) {
fs.mkdirSync(resolved, { recursive: true });
logger.info(`Download folder created: ${resolved}`);
}
const real = fs.realpathSync(resolved);
if (!isSafePath(resolved)) {
throw new Error("Path not allowed: system folders are blocked!");
}
return resolved;
} catch (err) {
return real;
}
catch (err) {
/**
* Handles invalid filesystem paths, permissions issues, or resolution failures.
* Logs diagnostic information before propagating a sanitized error.
*/
logger.error(`Invalid download path: ${userPath} — ${err.message}`);
throw new Error(`Invalid or inaccessible path: ${err.message}`);
}

View File

@@ -1,11 +1,28 @@
const { BrowserWindow, app } = require("electron");
const path = require("path");
/**
* Reference to the splash screen BrowserWindow instance.
* Used during application startup before main window is ready.
*/
let splashWindow = null;
/**
* Creates and displays the splash screen window.
*
* Responsibilities:
* - Initializes a frameless, always-on-top splash window
* - Loads splash HTML UI
* - Dynamically injects banner image depending on dev/production mode
* - Acts as startup visual feedback before main window loads
*/
function createSplashWindow() {
const splashOptions = {
/**
* Configuration for splash screen BrowserWindow.
* Optimized for minimal UI and fast startup display.
*/
const splashOptions = {
width: 400,
height: 300,
frame: false,
@@ -19,13 +36,29 @@ const splashOptions = {
},
};
/**
* Instantiates the splash screen window.
*/
splashWindow = new BrowserWindow(splashOptions);
/**
* Absolute path to splash HTML file used as initial UI.
* Loaded locally from application bundle or project folder.
*/
const splashPath = path.join(__dirname, "../public/splash.html");
splashWindow.loadFile(splashPath);
// Inject banner path for both dev and packaged app
/**
* Executes once splash HTML is fully loaded.
* Used here to inject dynamic assets (banner image path).
*/
splashWindow.webContents.on('did-finish-load', () => {
/**
* Resolves splash banner image path depending on runtime mode:
* - Production: bundled resources directory
* - Development: local build directory
*/
let bannerPath;
// Check if app is packaged
@@ -36,12 +69,21 @@ const splashOptions = {
bannerPath = path.join(__dirname, '../build/banner.png');
}
/**
* Injects runtime image source into splash DOM.
* Uses file:// protocol to load local image safely in Electron context.
*/
splashWindow.webContents.executeJavaScript(`
document.querySelector('img[alt="Freedom Loader"]').src = 'file:///${bannerPath.replace(/\\/g, '/')}';
`);
});
}
/**
* Closes and cleans up the splash screen window.
*
* Should be called once main window is ready to display.
*/
function closeSplashWindow() {
if (splashWindow) {
splashWindow.close();
@@ -49,6 +91,13 @@ function closeSplashWindow() {
}
}
/**
* Updates splash screen progress indicator from main process.
*
* Sends a step value to renderer via injected JS function.
*
* @param {number} step - Current initialization step or progress value
*/
function setSplashProgress(step) {
if (splashWindow) {
splashWindow.webContents.executeJavaScript(`window.setProgress(${step})`);

View File

@@ -1,14 +1,48 @@
/**
* Theme loading and parsing system.
*
* Supports:
* - Folder-based themes
* - ZIP-based themes
* - Theme validation against required schema
* - Image embedding as base64 data URIs
* - Cached theme registry with sorted priority order
*
* Acts as the core theme ingestion pipeline for the application UI.
*/
const fs = require("fs");
const path = require("path");
const { logger } = require("../server/logger");
const JSZip = require("jszip");
/**
* Maximum allowed size (in bytes) for theme images.
* Prevents memory abuse from large embedded assets.
*/
const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/**
* List of supported image file extensions for theme assets.
*/
const ALLOWED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"];
/**
* Allowed base filenames for theme background/cover images.
* Restricts theme asset resolution to known conventions.
*/
const ALLOWED_IMAGE_NAMES = ["cover", "background"];
/**
* Priority order used to sort themes in UI.
* Themes not listed are placed at the end.
*/
const THEME_ORDER = ["dark", "light"];
/**
* Required JSON schema paths for a valid theme definition.
* Each entry represents a nested property path that must exist.
*/
const REQUIRED_KEYS = [
["meta", "name"],
["meta", "author"],
@@ -20,10 +54,25 @@ const REQUIRED_KEYS = [
["style", "progressBar", "fill"],
];
/**
* Safely retrieves a deeply nested value from an object using a key path array.
*
* @param {object} obj - Target object
* @param {string[]} keys - Property path segments
* @returns {*} value or undefined if path is invalid
*/
function getNestedValue(obj, keys) {
return keys.reduce((acc, key) => acc?.[key], obj);
}
/**
* Validates theme JSON structure against required schema.
*
* Ensures all mandatory configuration keys exist before theme is accepted.
*
* @param {object} json - Parsed theme JSON
* @returns {{valid: boolean, reason?: string}}
*/
function validateThemeJson(json) {
for (const keyPath of REQUIRED_KEYS) {
if (getNestedValue(json, keyPath) === undefined) {
@@ -33,6 +82,14 @@ function validateThemeJson(json) {
return { valid: true };
}
/**
* Normalizes raw theme JSON into application-ready theme object.
*
* @param {string} themeId
* @param {object} themeJson
* @param {string|null} imageData - Base64 encoded theme image
* @returns {object} normalized theme object
*/
function buildThemeObject(themeId, themeJson, imageData) {
return {
id: themeId.toLowerCase(),
@@ -45,6 +102,17 @@ function buildThemeObject(themeId, themeJson, imageData) {
};
}
/**
* Extracts and encodes an image from theme archive into base64 data URI.
*
* Applies safety checks:
* - Rejects images exceeding MAX_IMAGE_SIZE
* - Converts file extension into valid MIME type
*
* @param {Buffer} buffer
* @param {string} filename
* @returns {string|null} data URI or null if invalid
*/
function extractImage(buffer, filename) {
if (buffer.length > MAX_IMAGE_SIZE) {
logger.warn(`Image too large, ignoring`);
@@ -55,6 +123,20 @@ function extractImage(buffer, filename) {
return `data:image/${mime};base64,${buffer.toString("base64")}`;
}
/**
* Loads a theme from a ZIP archive.
*
* Steps:
* - Extract ZIP content
* - Locate .theme.json file
* - Validate JSON structure
* - Extract optional image asset
* - Build normalized theme object
*
* @param {string} zipPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromZip(zipPath, themeId) {
try {
const zipBuffer = fs.readFileSync(zipPath);
@@ -91,6 +173,15 @@ async function loadThemeFromZip(zipPath, themeId) {
}
}
/**
* Loads a theme from a directory structure.
*
* Equivalent to ZIP loader but works on filesystem directly.
*
* @param {string} folderPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromFolder(folderPath, themeId) {
try {
const jsonFile = fs.readdirSync(folderPath).find(f => f.endsWith(".theme.json"));
@@ -124,13 +215,38 @@ async function loadThemeFromFolder(folderPath, themeId) {
}
}
/**
* In-memory cache of loaded themes.
* Avoids re-parsing filesystem on every request.
*/
let cachedThemes = null;
/**
* Active theme directory path used as source of truth.
*/
let themeFolderPath = null;
/**
* Sets the active theme directory path.
*
* @param {string} folderPath
*/
function setThemeFolderPath(folderPath) {
themeFolderPath = folderPath;
}
/**
* Loads all themes from the configured theme directory.
*
* Supports:
* - ZIP themes (.zip)
* - Folder themes (directory-based)
*
* Iterates through filesystem entries and delegates parsing
* to the appropriate loader (ZIP or folder).
*
* @returns {Promise<object[]>} List of valid theme objects
*/
async function loadThemesFromFolder() {
const themes = [];
@@ -154,18 +270,37 @@ async function loadThemesFromFolder() {
if (fs.statSync(filePath).isDirectory()) {
const theme = await loadThemeFromFolder(filePath, themeId);
if (theme) themes.push(theme);
continue;
}
}
return themes;
}
/**
* Returns sorting priority for a theme ID.
*
* Lower index = higher priority.
* Themes not in THEME_ORDER are placed at the end.
*
* @param {string} id
* @returns {number}
*/
function getThemeOrder(id) {
const index = THEME_ORDER.indexOf(id);
return index === -1 ? Infinity : index;
}
/**
* Initializes the theme system.
*
* Steps:
* - Sets theme folder path
* - Loads all themes from disk
* - Sorts themes by priority order
* - Caches result in memory
*
* @param {string} folderPath
*/
async function initThemes(folderPath) {
setThemeFolderPath(folderPath);
const themes = await loadThemesFromFolder();
@@ -174,10 +309,24 @@ async function initThemes(folderPath) {
logger.info(`Themes after sort: ${cachedThemes.map(t => t.id).join(", ")}`);
}
/**
* Returns cached themes.
*
* If themes are not loaded yet, returns empty array.
*
* @returns {object[]}
*/
function getThemes() {
return cachedThemes ?? [];
}
/**
* Reloads themes from disk and refreshes cache.
*
* Used when themes are modified at runtime without restart.
*
* @returns {Promise<object[]>}
*/
async function reloadThemes() {
const themes = await loadThemesFromFolder();
cachedThemes = themes.sort((a, b) => getThemeOrder(a.id) - getThemeOrder(b.id));

View File

@@ -1,21 +1,55 @@
/**
* Electron main window module dependencies.
* Handles BrowserWindow lifecycle and application UI shell.
*/
const { BrowserWindow, app } = require("electron");
const path = require("path");
const { logger } = require("../server/logger");
const { configFeatures } = require("../config");
const config = require("../config");
/**
* Singleton reference to the main Electron BrowserWindow instance.
* Ensures only one application window exists at runtime.
*/
let mainWindow = null;
/**
* Creates and initializes the main Electron application window.
*
* Responsibilities:
* - Ensures singleton window instance (prevents duplicates)
* - Configures window options (size, icon, preload, security settings)
* - Loads frontend application from local dev server
* - Handles lifecycle events (close, errors)
*
* @returns {Promise<BrowserWindow>} The created or existing window instance
*/
async function createMainWindow() {
/**
* Prevents multiple instances of the main window.
* Returns existing instance if already created.
*/
if (mainWindow) {
logger.warn("Window already exists, no new creation!");
return mainWindow;
}
const iconPath = config.localMode
/**
* Resolves application icon path depending on runtime mode:
* - Development: local build folder
* - Production: packaged Electron resources
*/
const iconPath = config.devMode
? path.join(__dirname, "../build/app-icon.ico")
: path.join(process.resourcesPath, "build/app-icon.ico");
/**
* Electron BrowserWindow configuration object.
* Defines UI behavior, security settings, and preload bridge.
*/
const windowOptions = {
title: `Freedom Loader ${config.version}`,
icon: iconPath,
@@ -35,6 +69,10 @@ async function createMainWindow() {
mainWindow = new BrowserWindow(windowOptions);
/**
* Loads the frontend application served by the local dev server.
* In production, this could be replaced with a file:// build.
*/
try {
await mainWindow.loadURL(`http://localhost:${config.applicationPort}`);
logger.info("Window loaded");
@@ -43,6 +81,10 @@ async function createMainWindow() {
throw err;
}
/**
* Cleans up window reference when the main window is closed.
* Prevents memory leaks and allows recreation if needed.
*/
mainWindow.on("closed", () => {
logger.info("Main window closed");
mainWindow = null;
@@ -51,6 +93,14 @@ async function createMainWindow() {
return mainWindow;
}
/**
* Returns the current main window instance.
*
* Useful for IPC handlers and background services
* needing access to the renderer process.
*
* @returns {BrowserWindow | null}
*/
function getMainWindow() {
return mainWindow;
}

View File

@@ -1,10 +1,33 @@
const { execFile } = require("child_process");
const { logger } = require("../server/logger");
/**
* Attempts to update the local yt-dlp binary using its built-in update command.
*
* This function:
* - Executes yt-dlp with the `-U` flag
* - Logs update output or warnings if update fails
* - Never throws: update failure is non-blocking by design
*
* @param {string} ytDlpPath - Absolute path to the yt-dlp executable
*/
function updateYtDlp(ytDlpPath) {
logger.info("yt-dlp update check starting...");
/**
* Executes yt-dlp CLI process in a non-interactive mode.
* Used here specifically for self-update via `-U` flag.
*/
execFile(ytDlpPath, ["-U"], (err, stdout) => {
/**
* Handles yt-dlp update result:
* - Logs warning if update fails (network, binary issues, permissions)
* - Logs stdout output when update succeeds
*/
if (err) logger.warn("yt-dlp update failed (continuing):", err.message);
else logger.info(`yt-dlp update: ${stdout.trim()}`);
});
}

BIN
build/app-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

106
config.js
View File

@@ -3,41 +3,129 @@ const { app } = require("electron");
const fs = require("fs");
const path = require("path");
const localMode = !app.isPackaged;
/**
* Indicates whether the application is running in development mode.
* Determined by Electron's packaging state.
*/
const devMode = process.env.NODE_ENV === "test" || !app?.isPackaged;
/**
* Resolves the configuration file path depending on runtime environment.
*
* In development:
* - Uses local config/config.dev.json
* - Creates it from default if missing
*
* In production:
* - Uses app userData/config.json
* - Creates it from bundled default config if missing
*
* @returns {string} Absolute path to the active configuration file
*/
function resolveConfigPath() {
if (localMode) {
if (devMode) {
const devConfigPath = path.join(__dirname, "config", "config.dev.json");
if (!fs.existsSync(devConfigPath)) {
const defaultConfigPath = path.join(__dirname, "config", "config.default.json");
const defaultConfigPath = path.join(
__dirname,
"config",
"config.default.json"
);
fs.copyFileSync(defaultConfigPath, devConfigPath);
}
return devConfigPath;
}
const userConfigPath = path.join(app.getPath("userData"), "config.json");
if (!fs.existsSync(userConfigPath)) {
const defaultConfigPath = path.join(process.resourcesPath, "config", "config.default.json");
const defaultConfigPath = path.join(
process.resourcesPath,
"config",
"config.default.json"
);
fs.copyFileSync(defaultConfigPath, userConfigPath);
}
return userConfigPath;
}
/**
* Path to the active configuration file used at runtime.
*/
const featuresPath = resolveConfigPath();
/**
* Loads and parses the feature configuration file.
*
* @returns {Object} Parsed feature flags configuration
* @throws {Error} If the configuration file is missing or invalid
*/
function loadFeatures() {
const raw = fs.readFileSync(featuresPath, "utf-8");
return JSON.parse(raw);
}
const configFeatures = loadFeatures();
/**
* Reloads feature flags from disk.
* Use this instead of the cached configFeatures snapshot
* when you need the latest user settings at call time.
*
* @returns {Object} Fresh feature flags from config file
*/
function reloadFeatures() {
try {
const raw = fs.readFileSync(featuresPath, "utf-8");
return JSON.parse(raw);
} catch (err) {
// Fallback to cached snapshot if file read fails
return configFeatures;
}
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart or reload is required.
*/
let configFeatures = loadFeatures();
module.exports = {
version: packageJson.version,
/**
* Application version from package.json
*/
version: devMode ? `dev-${packageJson.version}` : `v${packageJson.version}` ,
/**
* Backend Express / internal server port
*/
applicationPort: "8787",
localMode,
/**
* Indicates whether the app is running in development mode
*/
devMode,
/**
* Discord RPC application identifier
*/
DiscordRPCID: "1410934537051181146",
/**
* Runtime feature flags loaded from configuration file
*/
configFeatures,
featuresPath
}
/**
* Function to reload the config
*/
reloadFeatures,
/**
* Path to the active configuration file
*/
featuresPath,
};

View File

@@ -7,11 +7,13 @@
"addMetadata": true,
"verboseLogs": false,
"autoDownloadPlaylist": true,
"keepPlaylistOrder": false,
"createPlaylistFolders": true,
"customCodec": "h264",
"logSystem": true,
"outputTitleCheck": true,
"downloadSystem": true,
"notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark"
}

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",
],
};

107
main.js
View File

@@ -1,3 +1,8 @@
/**
* Handle crash application and avoid ghost instance
*/
const { app } = require("electron");
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
app.quit();
@@ -8,44 +13,97 @@ process.on("unhandledRejection", (reason) => {
app.quit();
});
const { app } = require("electron");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const path = require("path");
const { logger, logSessionStart, logSessionEnd } = require("./server/logger");
const { initAutoUpdater } = require("./app/autoUpdater");
const { startRPC, stopRPC } = require("./app/discordRPC");
/**
* True if this is the primary instance (lock acquired successfully)
*/
const isPrimaryInstance = app.requestSingleInstanceLock();
/**
* If we are on a second instance
*/
if (!isPrimaryInstance) {
app.quit();
process.exit(0);
}
/**
* Set App Name and AppID
*/
app.setName("Freedom Loader");
app.setAppUserModelId("com.masteracnolo.freedomloader");
/**
* Load the app dependencies for hardware choice
*/
const { logger, logSessionStart, logSessionEnd, logDir } = require("./server/logger");
const { configFeatures } = require("./config");
/**
* On start, enable or disable hardWareAcceleration
*/
if (!configFeatures.enableHardwareAcceleration){
app.disableHardwareAcceleration();
logger.info("Disabled Hardware Acceleration")
} else {
logger.info("Enable Hardware Acceleration")
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart could be required.
*/
const config = require("./config");
if(config.devMode){
/**
* Start devTron extensions - @see https://github.com/electron/devtron
*/
const { devtron } = require('@electron/devtron');
devtron.install();
logger.info("Loaded DevTron Extension")
}
/**
* Load the app dependencies
*/
const { initThemes } = require("./app/themeManager");
const { initAutoUpdater } = require("./app/autoUpdater");
const { startRPC, stopRPC } = require("./app/discordRPC");
const { checkNativeDependencies } = require("./app/dependencyCheck");
const { updateYtDlp } = require("./app/ytDlpUpdater");
const { createMainWindow, getMainWindow } = require("./app/windowManager");
const { registerIpcHandlers } = require("./app/ipcHandlers");
const { userThemesPath, initUserThemes } = require("./server/helpers/path.helpers");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const { userThemesPath, initUserThemes, isWindows, validateBinaries, defaultDownloadFolder } = require("./server/helpers/path.helpers");
app.setName("Freedom Loader");
app.setAppUserModelId("com.masteracnolo.freedomloader");
app.disableHardwareAcceleration();
if (!config.localMode) {
const gotLock = app.requestSingleInstanceLock();
if (gotLock) {
app.on("second-instance", () => {
logger.info("New instance detected, closing older...");
getMainWindow()?.destroy();
});
}
}
/**
* If another instance want to run
*/
app.on("second-instance", () => {
logger.info("Second instance detected");
const mainWindow = require("./app/windowManager").getMainWindow();
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
app.whenReady().then(async () => {
logSessionStart();
logSessionStart(logDir, defaultDownloadFolder);
createSplashWindow();
if (!config.localMode && !checkNativeDependencies()) return;
if (!config.devMode && !checkNativeDependencies()) return;
const { userYtDlp } = require("./server/helpers/path.helpers");
updateYtDlp(userYtDlp);
@@ -58,13 +116,14 @@ app.whenReady().then(async () => {
registerIpcHandlers(getMainWindow);
setSplashProgress(2); // Loading themes
validateBinaries();
initUserThemes();
await initThemes(userThemesPath);
await initThemes(userThemesPath);
setSplashProgress(3); // Almost ready
await createMainWindow();
await new Promise(resolve => setTimeout(resolve, 2000));
closeSplashWindow();
getMainWindow().show();

6378
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,39 @@
{
"name": "freedom-loader",
"productName": "Freedom Loader",
"version": "1.5.0",
"author": "MasterAcnolo",
"description": "Freedom Loader",
"version": "1.6.2-preview",
"author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Free and open-source GUI for yt-dlp — download video and audio from hundreds of platforms",
"homepage": "https://masteracnolo.github.io/Freedom-Loader-Site/",
"repository": {
"type": "git",
"url": "https://github.com/MasterAcnolo/Freedom-Loader.git"
},
"license": "GPL-3.0-only",
"bugs": {
"url": "https://github.com/MasterAcnolo/Freedom-Loader/issues"
},
"main": "main.js",
"scripts": {
"start": "electron --trace-warnings .",
"start": "electron --no-warnings .",
"start:warn": "electron --trace-warnings .",
"start:debug": "WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .",
"dev": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --no-warnings .\"",
"dev:warn": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"electron --trace-warnings .\"",
"dev:debug": "nodemon --watch server --watch app --watch main.js --watch config.js --ext js,json --exec \"WAYLAND_DEBUG=1 ELECTRON_ENABLE_LOGGING=1 ELECTRON_ENABLE_STACK_DUMPING=1 electron --enable-logging --v=1 .\"",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:rpm": "bash scripts/create-copr-package.sh",
"build:all": "electron-builder -wl",
"release": "bash scripts/release.sh",
"release:publish": "bash scripts/release.sh --publish",
"release:dry-run": "bash scripts/release.sh --dry-run",
"test": "jest",
"test:unit": "jest test/unit",
"update": "npm update"
},
"dependencies": {
@@ -23,8 +49,14 @@
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"electron": "^41.2.0",
"electron-builder": "^26.8.1"
"@electron/devtron": "^2.1.1",
"@jest/globals": "^30.4.1",
"@playwright/test": "^1.62.1",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
"jest": "^30.4.2",
"nodemon": "^3.1.14",
"playwright": "^1.62.1"
},
"build": {
"appId": "com.masteracnolo.freedomloader",
@@ -33,7 +65,8 @@
{
"provider": "github",
"owner": "MasterAcnolo",
"repo": "Freedom-Loader"
"repo": "Freedom-Loader",
"releaseType": "draft"
}
],
"files": [
@@ -55,10 +88,6 @@
"from": "build/confirm-icon.png",
"to": "confirm-icon.png"
},
{
"from": "build/app-icon.ico",
"to": "app-icon.ico"
},
{
"from": "build/banner.bmp",
"to": "banner.bmp"
@@ -71,22 +100,6 @@
"from": "build/error.png",
"to": "error.png"
},
{
"from": "ressources/yt-dlp.exe",
"to": "binaries/yt-dlp.exe"
},
{
"from": "ressources/ffmpeg.exe",
"to": "binaries/ffmpeg.exe"
},
{
"from": "ressources/ffprobe.exe",
"to": "binaries/ffprobe.exe"
},
{
"from": "ressources/deno.exe",
"to": "binaries/deno.exe"
},
{
"from": "config/config.default.json",
"to": "config/config.default.json"
@@ -94,7 +107,26 @@
],
"win": {
"target": "nsis",
"icon": "build/app-icon.ico"
"icon": "build/app-icon.ico",
"artifactName": "Freedom-Loader-Setup-${version}.${ext}",
"extraResources": [
{
"from": "resources/binaries/win-32/yt-dlp.exe",
"to": "binaries/yt-dlp.exe"
},
{
"from": "resources/binaries/win-32/ffmpeg.exe",
"to": "binaries/ffmpeg.exe"
},
{
"from": "resources/binaries/win-32/ffprobe.exe",
"to": "binaries/ffprobe.exe"
},
{
"from": "resources/binaries/win-32/deno.exe",
"to": "binaries/deno.exe"
}
]
},
"nsis": {
"oneClick": false,
@@ -106,6 +138,51 @@
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Freedom Loader"
},
"linux": {
"target": [
"AppImage",
"deb",
"snap"
],
"category": "Utility",
"icon": "build/app-icon.png",
"executableName": "freedom-loader",
"extraResources": [
{
"from": "resources/binaries/linux/yt-dlp",
"to": "binaries/yt-dlp"
},
{
"from": "resources/binaries/linux/ffmpeg",
"to": "binaries/ffmpeg"
},
{
"from": "resources/binaries/linux/ffprobe",
"to": "binaries/ffprobe"
},
{
"from": "resources/binaries/linux/deno",
"to": "binaries/deno"
}
]
},
"deb": {
"fpm": [
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
]
},
"rpm": {
"fpm": [
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
]
},
"snap": {
"confinement": "strict",
"plugs": [
"default",
"removable-media"
]
}
}
}

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.masteracnolo.freedomloader</id>
<name>Freedom Loader</name>
<summary>GUI for yt-dlp to download video and audio with metadata</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-only</project_license>
<developer id="com.masteracnolo">
<name>MasterAcnolo</name>
</developer>
<description>
<p>
Freedom Loader is a free and open-source graphical interface built
around yt-dlp, the powerful command-line media downloader. It lets
you download video and audio from hundreds of supported websites
without ever touching a terminal, with no ads, no tracking, and
no questionable third-party services.
</p>
<p>
Under the hood, Freedom Loader bundles yt-dlp and ffmpeg directly,
so there is nothing extra to install: paste a link, choose a
format and quality, and the app handles fetching, converting
and tagging the file for you.
</p>
<p>Features include:</p>
<ul>
<li>MP4 video or MP3 audio downloads with embedded metadata and thumbnails</li>
<li>Quality selection from best to specific resolutions (1080p, 720p, 480p)</li>
<li>Codec choice: H.264, H.265, AV1, VP9 and Theora</li>
<li>Playlist downloads with automatic per-playlist folders</li>
<li>Cookie-based access to age-restricted and member-only content</li>
<li>Custom theme system with a web-based theme creator</li>
<li>Real-time download progress with network speed indicator</li>
<li>Optional Discord Rich Presence integration</li>
<li>Automatic application and download-engine updates</li>
</ul>
<p>
DRM-protected content is not supported and cannot be downloaded.
</p>
</description>
<launchable type="desktop-id">freedom-loader.desktop</launchable>
<url type="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url>
<url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url>
<url type="vcs-browser">https://github.com/MasterAcnolo/Freedom-Loader</url>
<categories>
<category>AudioVideo</category>
<category>Utility</category>
<category>Network</category>
</categories>
<releases>
<release version="1.6.0" date="2026-06-23">
<description>
<p>Latest stable release.</p>
</description>
</release>
</releases>
<content_rating type="oars-1.1"/>
<provides>
<binary>freedom-loader</binary>
</provides>
</component>

9
package/copr-build.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/sh -xe
git clone --depth 1 https://github.com/MasterAcnolo/Freedom-Loader.git src
cd src
npm ci
npx electron-builder --linux dir
VERSION=$(node -p "require('./package.json').version")
tar czf "$resultdir/freedom-loader-$VERSION.tar.gz" -C dist linux-unpacked
sed "s/__VERSION__/$VERSION/" package/freedom-loader.spec > "$resultdir/freedom-loader.spec"

View File

@@ -0,0 +1,25 @@
Name: freedom-loader
Version: __VERSION__
Release: 1%{?dist}
Summary: GUI for yt-dlp media downloads
License: GPL-3.0-only
Source0: freedom-loader-__VERSION__.x86_64.rpm
BuildArch: x86_64
Requires: glibc, gtk3, nss, libXScrnSaver
%description
GUI for yt-dlp media downloads.
%install
rpm2cpio %{SOURCE0} | (cd %{buildroot} && cpio -idmv)
mkdir -p %{buildroot}/usr/bin
ln -sf "/opt/Freedom Loader/freedom-loader" %{buildroot}/usr/bin/freedom-loader
%files
/opt/*
/usr/bin/freedom-loader
/usr/share/applications/*
/usr/share/metainfo/*
/usr/share/icons/hicolor
%changelog

View File

@@ -1,27 +1,129 @@
const { contextBridge, ipcRenderer } = require("electron");
/**
* Exposes the main application API to the renderer process.
*
* This API acts as a secure bridge between the renderer and the
* Electron main process using IPC (Inter-Process Communication).
* All calls are explicitly whitelisted.
*/
contextBridge.exposeInMainWorld("electronAPI", {
/**
* Return process.platform to renderer
*/
getProcessPlatform: () => process.platform,
/**
* Returns the default system download directory.
*/
getDefaultDownloadPath: () => ipcRenderer.invoke("get-default-download-path"),
/**
* Opens a native dialog allowing the user to select a download folder.
*/
selectDownloadFolder: () => ipcRenderer.invoke("select-download-folder"),
/**
* Sends download progress value to the main process (used for taskbar / UI sync).
*
* @param {number} percent - Download progress percentage (0–100)
*/
setProgress: (percent) => ipcRenderer.send("set-progress", percent),
/**
* Retrieves application feature flags (runtime configuration).
*/
getFeatures: () => ipcRenderer.invoke("features"),
setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }),
/**
* Updates a feature flag dynamically at runtime.
*
* @param {string} key - Feature name
* @param {any} value - Feature value
*/
setFeature: (key, value) =>
ipcRenderer.invoke("set-feature", { key, value }),
/**
* Returns the current application version.
*/
getVersion: () => ipcRenderer.invoke("version"),
getValidatedDownloadPath: (path) => ipcRenderer.invoke("validate-download-path", path),
/**
* Validates a download path before using it for file operations.
*
* @param {string} path - Path to validate
*/
getValidatedDownloadPath: (path) =>
ipcRenderer.invoke("validate-download-path", path),
/**
* Retrieves available themes from the filesystem or config layer.
*/
getThemes: () => ipcRenderer.invoke("get-themes"),
reloadThemes: () => ipcRenderer.invoke("reload-themes")
/**
* Forces a reload of theme files (useful after modification/import).
*/
reloadThemes: () => ipcRenderer.invoke("reload-themes"),
});
// Contrôles de fenêtre et outils custom pour la topbar
/**
* Exposes window control and developer utilities for the custom topbar UI.
*
* These methods forward commands to the Electron main process via IPC.
*/
contextBridge.exposeInMainWorld("topbarAPI", {
/**
* Minimizes the application window.
*/
minimize: () => ipcRenderer.send("window-minimize"),
/**
* Toggles maximize/restore state of the application window.
*/
maximize: () => ipcRenderer.send("window-maximize"),
/**
* Closes the application window.
*/
close: () => ipcRenderer.send("window-close"),
/**
* Opens Chromium DevTools for debugging.
*/
openDevTools: () => ipcRenderer.send("open-devtools"),
/**
* Opens application logs directory or log viewer.
*/
openLogs: () => ipcRenderer.send("open-logs"),
/**
* Opens the official website or project homepage.
*/
openWebsite: () => ipcRenderer.send("open-website"),
/**
* Opens the theme directory or theme editor interface.
*/
openTheme: () => ipcRenderer.send("open-theme"),
/**
* Opens project wiki/documentation.
*/
openWiki: () => ipcRenderer.send("open-wiki"),
/**
* Opens the workshop or external content library.
*/
openWorkshop: () => ipcRenderer.send("open-workshop"),
openConfig: () => ipcRenderer.send("open-config")
});
/**
* Opens configuration/settings panel.
*/
openConfig: () => ipcRenderer.send("open-config"),
});

View File

@@ -106,10 +106,10 @@
</div>
<div class="setting-item">
<input type="checkbox" data-key="autoCheckInfo" id="autoCheckInfo">
<input data-key="keepPlaylistOrder" id="keepPlaylistOrder" type="checkbox">
<div class="setting-info">
<span class="setting-title">Auto Fetch Info</span>
<small>Automatically fetch information for new downloads.</small>
<span class="setting-title">Keep Playlist Order</span>
<small>Add index before output file, to keep the order.</small>
</div>
</div>
</div>
@@ -135,7 +135,15 @@
</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">
<div class="setting-info">
<span class="setting-title">Add Metadata</span>
@@ -175,28 +183,20 @@
</div>
</div>
<div class="setting-item">
<input type="checkbox" data-key="enableHardwareAcceleration" id="enableHardwareAcceleration">
<div class="setting-info">
<span class="setting-title">Enable Hardware Acceleration</span>
<small>Uses GPU acceleration for rendering when available. <br> (Be effective on next launch)</small>
</div>
</div>
</details>
<!-- Developer Settings -->
<details class="settings-section">
<summary>Developer Settings</summary>
<!-- <div class="setting-item">
<input type="checkbox" data-key="downloadSystem" id="downloadSystem">
<div class="setting-info">
<span class="setting-title">Download System</span>
<small>Enable/disable the internal download system.</small>
</div>
</div> -->
<!-- <div class="setting-item">
<input type="checkbox" data-key="logSystem" id="logSystem">
<div class="setting-info">
<span class="setting-title">Log System</span>
<small>Enable logging system.</small>
</div>
</div> -->
<div class="setting-item">
<input type="checkbox" data-key="verboseLogs" id="verboseLogs">
<div class="setting-info">
@@ -298,6 +298,7 @@
<!-- Scripts -->
<script src="script/toast.js"></script>
<script src="script/customConfiguration.js"></script>
<script src="script/clipboardPaste.js"></script>
<script src="script/custompath.js"></script>
<script src="script/settingsPanel.js"></script>

View File

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

View File

@@ -1,35 +1,40 @@
// Clipboard paste functionality for URL input
document.addEventListener('DOMContentLoaded', () => {
const pasteBtn = document.getElementById('pasteBtn');
const urlInput = document.getElementById('UrlInput');
/**
* Initializes clipboard paste functionality for the URL input field.
*
* When the user clicks the paste button, the system attempts to read
* the clipboard content and inject it into the URL input field.
* It also triggers an input event to notify any bound listeners.
*/
document.addEventListener("DOMContentLoaded", () => {
const pasteBtn = document.getElementById("pasteBtn");
const urlInput = document.getElementById("UrlInput");
if (pasteBtn && urlInput) {
pasteBtn.addEventListener('click', async () => {
try {
// Read text from clipboard
const text = await navigator.clipboard.readText();
// Paste into the URL input
urlInput.value = text;
// Trigger input event to ensure any listeners are notified
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
// Optional: Focus the input
urlInput.focus();
// Visual feedback
pasteBtn.style.backgroundColor = 'var(--form-button-bg-hover-color)';
setTimeout(() => {
pasteBtn.style.backgroundColor = '';
}, 200);
} catch (err) {
console.error('Failed to read clipboard:', err);
// Fallback: prompt user if clipboard API fails
alert('Unable to access clipboard. Please paste manually using Ctrl+V.');
}
});
if (!pasteBtn || !urlInput) return;
/**
* Handles clipboard paste action triggered by the paste button.
* Reads clipboard text and inserts it into the URL input field.
*/
pasteBtn.addEventListener("click", async () => {
try {
urlInput.value = await navigator.clipboard.readText();
// Notify frameworks / listeners bound to input changes
urlInput.dispatchEvent(new Event("input", { bubbles: true }));
urlInput.focus();
// Temporary UI feedback on paste action
pasteBtn.style.backgroundColor = "var(--form-button-bg-hover-color)";
setTimeout(() => {
pasteBtn.style.backgroundColor = "";
}, 200);
} catch (err) {
console.error("Clipboard access failed:", err);
alert(
"Unable to access clipboard. Please paste manually using Ctrl+V."
);
}
});
});
});

View File

@@ -0,0 +1,27 @@
async function initPlatformRestrictions() {
const platform = await window.electronAPI.getProcessPlatform();
const themeSelect = document.getElementById("themeSelect");
const refreshBtn = document.getElementById("refresh-themes-btn");
/**
* Is the OS windows
* @type {boolean}
*/
const isWindows = platform === "win32";
console.log(isWindows);
// If it's windows, the button is not disabled.
themeSelect.disabled = !isWindows;
refreshBtn.disabled = !isWindows;
themeSelect.classList.toggle("disabled", !isWindows);
refreshBtn.classList.toggle("disabled", !isWindows);
if (!isWindows) {
themeSelect.title = "Themes are only available on Windows";
refreshBtn.title = "Themes are only available on Windows";
}
}

View File

@@ -1,9 +1,24 @@
/**
* Initializes the download path system once the UI is ready.
*
* Responsibilities:
* - Restore last known user-selected download path (localStorage)
* - Validate it against backend rules
* - Synchronize with backend authoritative path
* - Setup folder selection UI interaction
*/
window.addEventListener("DOMContentLoaded", async () => {
const savePathElem = document.getElementById("savePath");
const form = document.getElementById("downloadForm");
// input caché = DERNIER chemin validé par le back
/**
* Hidden form field storing the validated download path
* used during download submission.
*
* Created dynamically if not already present in DOM.
*/
let hidden = document.getElementById("savePathInput");
if (!hidden) {
hidden = document.createElement("input");
hidden.type = "hidden";
@@ -12,6 +27,17 @@ window.addEventListener("DOMContentLoaded", async () => {
form.appendChild(hidden);
}
/**
* Applies a validated download path received from backend.
*
* Updates:
* - UI display (visible path)
* - tooltip (full path)
* - hidden form input (used for submission)
* - localStorage cache (UX persistence only, not authoritative)
*
* @param {string} path - validated absolute download path
*/
async function applyPathFromBack(path) {
savePathElem.textContent = path;
savePathElem.title = path;
@@ -19,6 +45,14 @@ window.addEventListener("DOMContentLoaded", async () => {
localStorage.setItem("customDownloadPath", path); // UX only
}
/**
* Loads the initial download path on application startup.
*
* Priority order:
* 1. cached localStorage value (UX only, not trusted)
* 2. backend validated version of cached path
* 3. backend default fallback path if validation fails
*/
async function loadInitialPath() {
// On affiche ce que le user a vu la dernière fois (UX)
const cached = localStorage.getItem("customDownloadPath");
@@ -42,13 +76,16 @@ window.addEventListener("DOMContentLoaded", async () => {
await loadInitialPath();
document
.getElementById("changePath")
.addEventListener("click", async () => {
/**
* Opens system folder selector and updates download path.
*
* The selected folder is validated by Electron backend
* before being applied to the UI and persisted.
*/
document.getElementById("changePath").addEventListener("click", async () => {
try {
// selectDownloadFolder already returns a validated path
const validatedPath =
await window.electronAPI.selectDownloadFolder();
const validatedPath = await window.electronAPI.selectDownloadFolder();
if (!validatedPath) return; // cancelled

View File

@@ -1,6 +1,22 @@
const themeSelect = document.getElementById("themeSelect");
/**
* Currently selected themes loaded from backend.
* Used as cache for UI theme switching.
*/
let loadedThemes = [];
/**
* Applies a theme object to the application UI by mapping
* theme configuration values to CSS variables and DOM properties.
*
* This function mutates:
* - document.body (background styling)
* - :root CSS variables (design system tokens)
* - subtitle DOM element (theme metadata display)
*
* @param {Object} theme - Theme definition object
* @param {Object} theme.style - Style configuration
*/
function applyTheme(theme) {
const root = document.documentElement;
const style = theme.style;
@@ -15,72 +31,75 @@ function applyTheme(theme) {
document.body.style.backgroundImage = "";
}
// Colors
root.style.setProperty("--background-color", style.colors?.background || "");
root.style.setProperty("--default-text-color", style.colors?.text?.default || "");
root.style.setProperty("--subtitle-color", style.colors?.text?.subtitle || "");
root.style.setProperty("--audio-only-label-color", style.colors?.text?.audioOnly || "");
// CSS variables (design tokens)
root.style.setProperty("--background-color", style.colors?.background || "#121212");
root.style.setProperty("--default-text-color", style.colors?.text?.default || "#e0e0e0");
root.style.setProperty("--subtitle-color", style.colors?.text?.subtitle || "#2196f3");
root.style.setProperty("--audio-only-label-color", style.colors?.text?.audioOnly || "#f5f5f5");
// Form
root.style.setProperty("--form-bg-color", style.form?.background || "");
root.style.setProperty("--form-input-bg-color", style.form?.input?.background || "");
root.style.setProperty("--form-input-border-color", style.form?.input?.border || "");
root.style.setProperty("--form-input-border-focus-color", style.form?.input?.borderFocus || "");
root.style.setProperty("--form-input-text-color", style.form?.input?.text || "");
root.style.setProperty("--form-input-placeholder-color", style.form?.input?.placeholder || "");
root.style.setProperty("--form-button-bg-color", style.form?.button?.background || "");
root.style.setProperty("--form-button-text-color", style.form?.button?.text || "");
root.style.setProperty("--form-button-bg-hover-color", style.form?.button?.hover || "");
root.style.setProperty("--paste-button-icon-color", style.form?.pasteButtonIcon || "");
root.style.setProperty("--form-bg-color", style.form?.background || "#1e1e1e");
root.style.setProperty("--form-input-bg-color", style.form?.input?.background || "#2a2a2a");
root.style.setProperty("--form-input-border-color", style.form?.input?.border || "#444444");
root.style.setProperty("--form-input-border-focus-color", style.form?.input?.borderFocus || "#2196f3");
root.style.setProperty("--form-input-text-color", style.form?.input?.text || "#f5f5f5");
root.style.setProperty("--form-input-placeholder-color", style.form?.input?.placeholder || "#888888");
root.style.setProperty("--form-button-bg-color", style.form?.button?.background || "#2196f3");
root.style.setProperty("--form-button-text-color", style.form?.button?.text || "#ffffff");
root.style.setProperty("--form-button-bg-hover-color", style.form?.button?.hover || "#1976d2");
root.style.setProperty("--paste-button-icon-color", style.form?.pasteButtonIcon || "#ffffff");
// Checkbox
root.style.setProperty("--checkbox-unchecked-bg-color", style.checkbox?.background?.unchecked || "");
root.style.setProperty("--checkbox-checked-bg-color", style.checkbox?.background?.checked || "");
root.style.setProperty("--checkbox-checkmark-border-color", style.checkbox?.checkmarkBorder || "");
root.style.setProperty("--checkbox-pulse-color", style.checkbox?.pulse || "");
root.style.setProperty("--checkbox-unchecked-bg-color", style.checkbox?.background?.unchecked || "#555555");
root.style.setProperty("--checkbox-checked-bg-color", style.checkbox?.background?.checked || "#2196f3");
root.style.setProperty("--checkbox-checkmark-border-color", style.checkbox?.checkmarkBorder || "#ffffff");
root.style.setProperty("--checkbox-pulse-color", style.checkbox?.pulse || "rgba(33, 150, 243, 0.5)");
// Download
root.style.setProperty("--download-status-color", style.download?.status || "");
root.style.setProperty("--download-status-color", style.download?.status || "#2196f3");
// Progress bar
root.style.setProperty("--progress-bar-bg-color", style.progressBar?.background || "");
root.style.setProperty("--progress-bar-fill-color", style.progressBar?.fill || "");
root.style.setProperty("--progress-bar-bg-color", style.progressBar?.background || "#444444");
root.style.setProperty("--progress-bar-fill-color", style.progressBar?.fill || "#2196f3");
// Video info
root.style.setProperty("--video-info-bg-color", style.videoInfo?.background || "");
root.style.setProperty("--video-info-text-color", style.videoInfo?.text || "");
root.style.setProperty("--video-info-heading-color", style.videoInfo?.heading || "");
root.style.setProperty("--video-info-list-color", style.videoInfo?.list || "");
root.style.setProperty("--video-info-list-strong-color", style.videoInfo?.strong || "");
root.style.setProperty("--video-info-link-color", style.videoInfo?.link || "");
root.style.setProperty("--video-info-link-hover-color", style.videoInfo?.linkHover || "");
root.style.setProperty("--video-info-bg-color", style.videoInfo?.background || "#1a1a1a");
root.style.setProperty("--video-info-text-color", style.videoInfo?.text || "#e0e0e0");
root.style.setProperty("--video-info-heading-color", style.videoInfo?.heading || "#2196f3");
root.style.setProperty("--video-info-list-color", style.videoInfo?.list || "#cccccc");
root.style.setProperty("--video-info-list-strong-color", style.videoInfo?.strong || "#ffffff");
root.style.setProperty("--video-info-link-color", style.videoInfo?.link || "#2196f3");
root.style.setProperty("--video-info-link-hover-color", style.videoInfo?.linkHover || "#1976d2");
// Playlist
root.style.setProperty("--playlist-background-color", style.playlist?.background || "");
root.style.setProperty("--playlist-background-color", style.playlist?.background || "#303030");
// Settings
root.style.setProperty("--settings-button-bg-color", style.settings?.button?.background || "");
root.style.setProperty("--settings-button-text-color", style.settings?.button?.text || "");
root.style.setProperty("--open-theme-button-bg-color", style.settings?.openThemeButton?.background || "");
root.style.setProperty("--open-theme-button-text-color", style.settings?.openThemeButton?.text || "");
root.style.setProperty("--open-json-button-bg-color", style.settings?.openJsonButton?.background || style.settings?.button?.background || "");
root.style.setProperty("--open-json-button-text-color", style.settings?.openJsonButton?.text || style.settings?.button?.text || "");
root.style.setProperty("--settings-modal-bg-color", style.settings?.background?.modal || "");
root.style.setProperty("--settings-section-bg-color", style.settings?.background?.section || "");
root.style.setProperty("--settings-text-color", style.settings?.text || "");
root.style.setProperty("--settings-subtitle-color", style.settings?.subtitle || "");
root.style.setProperty("--settings-button-bg-color", style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--settings-button-text-color", style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--open-theme-button-bg-color", style.settings?.openThemeButton?.background || "#2196f3");
root.style.setProperty("--open-theme-button-text-color", style.settings?.openThemeButton?.text || "#ffffff");
root.style.setProperty("--open-json-button-bg-color", style.settings?.openJsonButton?.background || style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--open-json-button-text-color", style.settings?.openJsonButton?.text || style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--settings-modal-bg-color", style.settings?.background?.modal || "#1e1e1e");
root.style.setProperty("--settings-section-bg-color", style.settings?.background?.section || "#2a2a2a");
root.style.setProperty("--settings-text-color", style.settings?.text || "#e0e0e0");
root.style.setProperty("--settings-subtitle-color", style.settings?.subtitle || "#aaaaaa");
// Subtitle
const subtitleEl = document.getElementById("subtitle");
if (subtitleEl) subtitleEl.textContent = theme.subtitle || theme.name;
}
/**
* Persists selected theme ID into Electron backend features storage.
*
* @param {string} themeId
*/
function saveTheme(themeId) {
window.electronAPI.setFeature("theme", themeId);
}
/**
* Populates theme dropdown selector with available themes.
*
* @param {Array} themes
*/
function populateThemeSelect(themes) {
themeSelect.innerHTML = "";
for (const theme of themes) {
const option = document.createElement("option");
option.value = theme.id;
@@ -89,35 +108,143 @@ function populateThemeSelect(themes) {
}
}
/**
* Initializes theme system:
* - loads themes from backend
* - restores saved theme
* - applies selected theme
*/
async function initThemes() {
loadedThemes = await window.electronAPI.getThemes();
if (!loadedThemes.length) return;
if (!loadedThemes.length) {
const defaultTheme = {
id: "dark",
name: "Dark",
subtitle: "Darkness is my ally",
style: {
background: {
size: "cover",
position: "center",
attachment: "fixed"
},
colors: {
background: "#121212",
text: {
default: "#e0e0e0",
subtitle: "#2196f3",
audioOnly: "#f5f5f5"
}
},
form: {
background: "#1e1e1e",
input: {
background: "#2a2a2a",
border: "#444444",
borderFocus: "#2196f3",
text: "#f5f5f5",
placeholder: "#888888"
},
button: {
background: "#2196f3",
text: "#ffffff",
hover: "#1976d2"
},
pasteButtonIcon: "#ffffff"
},
checkbox: {
background: {
unchecked: "#555555",
checked: "#2196f3"
},
checkmarkBorder: "#ffffff",
pulse: "rgba(33, 150, 243, 0.5)"
},
download: {
status: "#2196f3"
},
progressBar: {
background: "#444444",
fill: "#2196f3"
},
videoInfo: {
background: "#1a1a1a",
text: "#e0e0e0",
heading: "#2196f3",
list: "#cccccc",
strong: "#ffffff",
link: "#2196f3",
linkHover: "#1976d2"
},
playlist: {
background: "#303030"
},
settings: {
button: {
background: "#1e1e1e",
text: "#e0e0e0"
},
openThemeButton: {
background: "#2196f3",
text: "#ffffff"
},
openJsonButton: {
background: "#2196f3",
text: "#ffffff"
},
background: {
modal: "#1e1e1e",
section: "#2a2a2a"
},
text: "#e0e0e0",
subtitle: "#aaaaaa"
}
}
};
loadedThemes = [defaultTheme];
}
populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures();
const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id;
applyTheme(theme);
}
/**
* Handles theme selection change from UI dropdown.
*/
themeSelect.addEventListener("change", (e) => {
const theme = loadedThemes.find(t => t.id === e.target.value);
if (theme) {
applyTheme(theme);
saveTheme(theme.id);
}
if (!theme) return;
applyTheme(theme);
saveTheme(theme.id);
});
/**
* Reloads themes from backend and reapplies current selection.
*/
async function refreshThemes() {
loadedThemes = await window.electronAPI.reloadThemes();
populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures();
const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id;

View File

@@ -2,9 +2,35 @@ const form = document.getElementById("downloadForm");
const statusDiv = document.getElementById("downloadStatus");
const button = form.querySelector("button[type=\"submit\"]");
const stopBtn = document.getElementById("stopBtn");
/**
* Indicates whether a download process is currently active.
* Used to prevent duplicate submissions and to gate UI actions
* such as disabling the form or showing progress.
*/
let isDownloading = false;
/**
* Indicates whether the current download was explicitly
* stopped by the user via the cancel action.
*
* Used to differentiate between:
* - user-initiated cancellation
* - failures or natural completion
*/
let userStoppedDownload = false;
/**
* Handles download form submission.
*
* - Validates user input (URL)
* - Disables UI during download
* - Sends request to backend `/download`
* - Manages success / error states
* - Restores UI state after completion
*/
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -61,6 +87,13 @@ form.addEventListener("submit", async (e) => {
}
});
/**
* Displays initial download progress UI immediately
* when a download starts.
*
* Initializes progress bar, speed indicator, stage text
* and shows the stop button.
*/
function showInitialProgress() {
const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBarText = document.getElementById("downloadProgressText");
@@ -84,6 +117,14 @@ function showInitialProgress() {
if (stopBtn) stopBtn.style.display = "inline-flex";
}
/**
* Cancels the current download process.
*
* Sends a request to backend `/download/cancel`,
* updates UI state and resets progress indicators.
*
* Prevents duplicate calls if no download is active.
*/
async function stopDownload() {
if (!isDownloading) return;
@@ -109,6 +150,12 @@ async function stopDownload() {
}
}
/**
* Resets all download progress UI elements to their
* default hidden state.
*
* Used after completion or cancellation of a download.
*/
function resetProgressBar() {
const progressWrapper = document.getElementById("downloadProgressWrapper");
if (progressWrapper) progressWrapper.style.display = "none";

View File

@@ -1,23 +1,44 @@
/**
* Converts a raw upload date string (format YYYYMMDD)
* into a human-readable format (DD/MM/YYYY).
*
* Returns "Inconnue" if the input is invalid or malformed.
*/
function formatDate(dateStr) {
if (!dateStr || dateStr.length !== 8) return "Inconnue";
return `${dateStr.slice(6,8)}/${dateStr.slice(4,6)}/${dateStr.slice(0,4)}`;
}
/**
* Converts a file size in bytes into megabytes (MB).
*
* Returns a formatted string with 2 decimals (e.g. "12.34 Mo").
* If input is falsy, returns "Inconnue".
*/
function formatSize(bytes) {
return bytes ? (bytes / (1024*1024)).toFixed(2) + " Mo" : "Inconnue";
}
/**
* Fetches video or playlist metadata from the backend `/info` endpoint.
*
* Sends a POST request with URL-encoded body.
* Handles network errors, HTTP errors and JSON parsing safely.
*
* @param {string} url - Video or playlist URL
* @returns {Promise<Object>} Parsed metadata or error object
*/
async function fetchVideoInfo(url) {
try {
const res = await fetch("/info", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ url }),
const res = await fetch(`/info?url=${encodeURIComponent(url)}`, {
method: "GET",
headers: {"Accept": "application/json"}
});
if (!res.ok) return { error: `An Error occured when fetching info` };
const data = await res.json();
if (!res.ok) return {error: `An Error occured when fetching info`};
if (!data) return { error: "Data is Missing" };
return data;
@@ -26,9 +47,19 @@ async function fetchVideoInfo(url) {
}
}
/**
* Initializes the video info system UI.
*
* - Listens to URL input changes
* - Automatically fetches video/playlist metadata if enabled
* - Renders either playlist UI or single video UI
* - Handles loading states and caching (lastFetchedUrl)
*
* Also manages dynamic playlist title injection into the form.
*/
async function init() {
// Main input listener: triggers auto-fetch when URL changes
document.addEventListener("DOMContentLoaded", () => {
const urlInput = document.getElementById("UrlInput");
const infoDiv = document.getElementById("videoInfo");
@@ -240,6 +271,6 @@ async function init() {
})
};
}
init();

View File

@@ -1,40 +1,65 @@
/**
* DOM elements used for download progress UI rendering.
* These elements are updated in real-time via SSE streams.
*/
const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBar = document.getElementById("downloadProgress");
const progressBarText = document.getElementById("downloadProgressText")
const progressBarText = document.getElementById("downloadProgressText");
const speedElement = document.getElementById("downloadSpeedText");
const stageElement = document.getElementById("downloadStage");
const playlistInfoElement = document.getElementById("playlistInfoText");
/**
* Server-Sent Events streams for real-time download updates.
* Each stream is responsible for a specific aspect of the download state.
*/
const speedEvt = new EventSource("/download/speed");
const stageEvt = new EventSource("/download/stage");
const playlistInfoEvt = new EventSource("/download/playlist-info");
/**
* Resets the progress UI to its initial state.
* Hides all dynamic elements and clears displayed values.
*/
function startProgress() {
progressBar.style.width = "0%";
progressBarText.innerHTML = "0%";
speedElement.innerHTML = "0 Mib/s";
}
/**
* Makes the progress UI visible and enables related controls
* such as the stop button.
*/
function showProgress() {
progressWrapper.style.display = "block";
progressBarText.style.display = "block";
speedElement.style.display = "block";
// Show stop button
const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "inline-flex";
}
/**
* Updates the progress bar UI with the given percentage.
* Automatically triggers UI visibility if not already shown.
*
* @param {number} percent - Download progress percentage (0–100)
*/
function updateProgress(percent) {
// Show progress div and stop button only when percent > 0
if (percent > 0 && progressWrapper.style.display !== "block") {
showProgress();
}
progressBar.style.width = `${percent}%`;
progressBarText.innerHTML = `${percent}%`;
}
/**
* Fully resets the progress UI and hides all download indicators.
* Called after completion or cancellation.
*/
function resetProgress() {
progressBar.style.width = "0%";
progressBarText.textContent = "";
@@ -50,13 +75,19 @@ function resetProgress() {
playlistInfoElement.textContent = "";
playlistInfoElement.style.display = "none";
// Hide stop button
const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "none";
}
// SSE Connexion
/**
* Main SSE stream handling download progress updates.
* Supports:
* - reset signal
* - done signal
* - percentage updates
*/
const evtSource = new EventSource("/download/progress");
evtSource.onmessage = e => {
if (e.data === "reset") {
startProgress();
@@ -65,34 +96,41 @@ evtSource.onmessage = e => {
}
if (e.data === "done") {
// Keep progress bar visible for better UX, let toast handle the feedback
setTimeout(() => {
resetProgress();
window.electronAPI.setProgress(-1);
}, 2000); // Wait 2s so user sees 100% progress
window.electronAPI.setProgress(-1);
}, 2000);
return;
}
const percent = parseFloat(e.data);
if (!isNaN(percent)) {
updateProgress(percent);
window.electronAPI.setProgress(percent); // Update Task Bar
// Don't hide at 100%, wait for "done" signal instead
window.electronAPI.setProgress(percent);
}
};
/**
* SSE stream: download speed updates (e.g. "5.2 MiB/s").
*/
speedEvt.onmessage = e => {
speedElement.style.display = "block";
speedElement.textContent = e.data; // ex: "5.2MiB/s"
speedElement.textContent = e.data;
};
/**
* SSE stream: download stage updates (e.g. downloading, merging, extracting).
*/
stageEvt.onmessage = e => {
stageElement.style.display = "block";
stageElement.textContent = e.data; // ex: "📥 Downloading..."
stageElement.textContent = e.data;
};
/**
* SSE stream: playlist progress updates (e.g. "Item 1 of 15").
*/
playlistInfoEvt.onmessage = e => {
playlistInfoElement.style.display = "block";
playlistInfoElement.textContent = e.data; // ex: "Item 1 of 15"
playlistInfoElement.textContent = e.data;
};

View File

@@ -1,66 +1,105 @@
/**
* Settings panel DOM elements used to control application feature flags
* and configuration UI interactions.
*/
const settingsPanel = document.getElementById("settings-panel");
const settingsModal = document.querySelector(".settings-modal");
const settingsButton = document.getElementById("settings-button");
/**
* Tracks current settings panel state.
* NOTE: actual visibility is derived from DOM style in current implementation.
*/
let isOpen = false;
/**
* Toggles visibility of the settings panel when clicking the settings button.
*/
settingsButton.addEventListener("click", () => {
const isOpen = settingsPanel.style.display === "block";
settingsPanel.style.display = isOpen ? "none" : "block";
const currentlyOpen = settingsPanel.style.display === "block";
settingsPanel.style.display = currentlyOpen ? "none" : "block";
});
/**
* Closes the settings panel and resets internal state.
*/
function closePanel() {
isOpen = false;
settingsPanel.style.display = "none";
isOpen = false;
settingsPanel.style.display = "none";
}
/**
* Loads feature flags from Electron backend and syncs them
* with corresponding UI controls (checkboxes, selects).
*
* Also registers change listeners to persist updates in real time.
*/
async function loadSettings() {
const features = await window.electronAPI.getFeatures();
document.querySelectorAll(".settings-section input, .settings-section select").forEach(el => {
const key = el.dataset.key;
if (features[key] !== undefined) {
if (el.type === "checkbox") el.checked = features[key];
else if (el.tagName === "SELECT") el.value = features[key];
}
document
.querySelectorAll(".settings-section input, .settings-section select")
.forEach((el) => {
const key = el.dataset.key;
if (!key) return;
el.addEventListener("change", () => {
if (key === "theme") return;
let value = el.type === "checkbox" ? el.checked : el.value;
window.electronAPI.setFeature(key, value);
if (features[key] !== undefined) {
if (el.type === "checkbox") el.checked = features[key];
else if (el.tagName === "SELECT") el.value = features[key];
}
el.addEventListener("change", () => {
if (key === "theme") return;
const value = el.type === "checkbox" ? el.checked : el.value;
window.electronAPI.setFeature(key, value);
});
});
});
}
// Open The JSON
document.getElementById("open-json-btn").addEventListener("click", () => {
window.topbarAPI.openConfig();
/**
* Opens configuration JSON file via Electron topbar API.
*/
document.getElementById("open-json-btn")
?.addEventListener("click", () => {
window.topbarAPI.openConfig();
});
/**
* Opens theme directory via Electron topbar API.
*/
document.getElementById("open-theme")
?.addEventListener("click", () => {
window.topbarAPI.openTheme();
});
/**
* Refreshes theme list and updates UI selector with a short animation.
*/
document.getElementById("refresh-themes-btn")
?.addEventListener("click", function () {
this.classList.add("spinning");
window.refreshThemes();
setTimeout(() => {
this.classList.remove("spinning");
}, 600);
});
/**
* Clicking the overlay outside the modal closes the settings panel.
*/
settingsPanel?.addEventListener("click", closePanel);
/**
* Prevents modal clicks from closing the settings panel
* by stopping event propagation to the overlay.
*/
settingsModal?.addEventListener("click", (e) => {
e.stopPropagation();
});
// Open Theme Folder
document.getElementById("open-theme").addEventListener("click", () => {
window.topbarAPI.openTheme();
});
// Refresh Themes
document.getElementById("refresh-themes-btn").addEventListener("click", function() {
this.classList.add("spinning");
window.refreshThemes();
setTimeout(() => {
this.classList.remove("spinning");
}, 600);
});
/* clic sur l'overlay = fermer */
settingsPanel.addEventListener("click", closePanel);
/* clic dans la modal = stop propagation */
settingsModal.addEventListener("click", (e) => {
e.stopPropagation();
});
loadSettings();
/**
* Initializes settings UI by loading backend feature flags.
*/
loadSettings();

View File

@@ -1,17 +1,26 @@
window.showToast = function(message, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
if (!container) return;
/**
* Displays a toast notification in the global UI container.
*
* Toasts are automatically appended to `#toast-container`,
* support multiple visual types, and can auto-dismiss after a delay.
*
* @param {string} message - Message displayed inside the toast
* @param {"success"|"error"|"warning"|"info"} [type="info"] - Visual style of the toast
* @param {number} [duration=4000] - Auto-dismiss delay in milliseconds (0 disables auto removal)
* @returns {HTMLElement|null} The created toast element or null if container is missing
*/
window.showToast = function (message, type = "info", duration = 4000) {
const container = document.getElementById("toast-container");
if (!container) return null;
// Create toast element
const toast = document.createElement('div');
toast.classList.add('toast', type);
const toast = document.createElement("div");
toast.classList.add("toast", type);
// Icons for different types
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: 'ℹ'
success: "✓",
error: "✕",
warning: "⚠",
info: "ℹ"
};
const icon = icons[type] || icons.info;
@@ -22,14 +31,11 @@ window.showToast = function(message, type = 'info', duration = 4000) {
<button class="toast-close" title="Close">×</button>
`;
// Add close functionality
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => removeToast(toast));
const closeBtn = toast.querySelector(".toast-close");
closeBtn.addEventListener("click", () => removeToast(toast));
// Add to container
container.appendChild(toast);
// Auto remove after duration
if (duration > 0) {
setTimeout(() => removeToast(toast), duration);
}
@@ -37,18 +43,41 @@ window.showToast = function(message, type = 'info', duration = 4000) {
return toast;
};
/**
* Removes a toast element with a small exit animation.
*
* @param {HTMLElement} toast
*/
function removeToast(toast) {
if (!toast.parentElement) return;
toast.classList.add('removing');
if (!toast?.parentElement) return;
toast.classList.add("removing");
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
toast.remove();
}, 300);
}
window.showSuccess = (message, duration = 4000) => window.showToast(message, 'success', duration);
window.showError = (message, duration = 5000) => window.showToast(message, 'error', duration);
window.showWarning = (message, duration = 4000) => window.showToast(message, 'warning', duration);
window.showInfo = (message, duration = 4000) => window.showToast(message, 'info', duration);
/**
* Shortcut: success toast
*/
window.showSuccess = (message, duration = 4000) =>
window.showToast(message, "success", duration);
/**
* Shortcut: error toast
*/
window.showError = (message, duration = 5000) =>
window.showToast(message, "error", duration);
/**
* Shortcut: warning toast
*/
window.showWarning = (message, duration = 4000) =>
window.showToast(message, "warning", duration);
/**
* Shortcut: info toast
*/
window.showInfo = (message, duration = 4000) =>
window.showToast(message, "info", duration);

View File

@@ -1,16 +1,22 @@
// Ajout des listeners pour la topbar
/**
* Initializes topbar button event listeners and binds them
* to the Electron preload API exposed in `window.topbarAPI`.
*
* This function is safe to call once DOM is ready.
* It silently aborts if the API or elements are missing.
*/
function setupTopbarListeners() {
const { topbarAPI } = window;
if (!topbarAPI) return;
const minBtn = document.getElementById('minimize-btn');
const maxBtn = document.getElementById('maximize-btn');
const closeBtn = document.getElementById('close-btn');
const devtoolsBtn = document.getElementById('devtools-btn');
const logsBtn = document.getElementById('logs-btn');
const websiteBtn = document.getElementById('website-btn');
const wikiBtn = document.getElementById('wiki-btn');
const workshopBtn = document.getElementById('workshop-btn');
const minBtn = document.getElementById("minimize-btn");
const maxBtn = document.getElementById("maximize-btn");
const closeBtn = document.getElementById("close-btn");
const devtoolsBtn = document.getElementById("devtools-btn");
const logsBtn = document.getElementById("logs-btn");
const websiteBtn = document.getElementById("website-btn");
const wikiBtn = document.getElementById("wiki-btn");
const workshopBtn = document.getElementById("workshop-btn");
if (minBtn) minBtn.onclick = () => topbarAPI.minimize();
if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize();
@@ -22,12 +28,30 @@ function setupTopbarListeners() {
if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop();
}
document.addEventListener('DOMContentLoaded', setupTopbarListeners);
/**
* Registers topbar event listeners after DOM is fully loaded.
*/
document.addEventListener("DOMContentLoaded", setupTopbarListeners);
const features = await window.electronAPI.getFeatures();
/**
* Applies UI layout adjustments depending on feature flags
* provided by the Electron backend.
*
* Specifically handles:
* - hiding custom topbar
* - adjusting layout spacing
* - repositioning theme switcher
*/
(async function applyFeatureLayout() {
const features = await window.electronAPI.getFeatures();
if(!features.customTopBar){
document.getElementById("topbar").style.display = "none";
document.getElementById("container").style.marginTop = "0";
document.getElementById("theme-switcher").style.top = "30px";
}
if (!features.customTopBar) {
const topbar = document.getElementById("topbar");
const container = document.getElementById("container");
const themeSwitcher = document.getElementById("theme-switcher");
if (topbar) topbar.style.display = "none";
if (container) container.style.marginTop = "0";
if (themeSwitcher) themeSwitcher.style.top = "30px";
}
})();

View File

@@ -40,8 +40,7 @@ form button:hover {
form#downloadForm {
display: flex;
gap: 0.5em;
max-width: 600px;
width: 100%;
width: 350px;
justify-content: center;
}

View File

@@ -16,6 +16,14 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
/* Mark element as disabled */
.disabled {
pointer-events: none;
opacity: 0.5;
filter: grayscale(40%);
cursor: not-allowed;
}
/* Because why not */
#reference{
position: fixed;

BIN
resources/binaries/linux/yt-dlp Executable file

Binary file not shown.

116
scripts/create-copr-package.sh Executable file
View File

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

187
scripts/release.sh Executable file
View File

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

View File

@@ -4,11 +4,47 @@ const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const { configFeatures } = require("../../config");
const listeners = [];
const speedListeners = [];
const stageListeners = [];
const playlistInfoListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download progress updates.
*
* Each listener receives a percentage value between
* 0 and 100 during the download lifecycle.
*/
const progressListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download speed updates.
*/
const speedListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download stage updates.
*/
const stageListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* playlist progress information.
*/
const playlistListeners = [];
/**
* Handles download requests and starts a yt-dlp download.
*
* Validates user input, prepares download options and
* delegates the download process to the download service.
*
* Once the download completes successfully, a desktop
* notification may be displayed depending on the
* application configuration.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function downloadController(req, res) {
try {
const options = {
@@ -25,17 +61,30 @@ async function downloadController(req, res) {
return res.status(400).send("Save Path Not Allowed.");
}
// Get output folder when the download is finished
const outputFolder = await fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners);
const outputFolder = await fetchDownload(
options,
progressListeners,
speedListeners,
stageListeners,
playlistListeners
);
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
res.send("Download Done !");
} catch (err) {
logger.error(`Server Error in /download : ${err.message}`);
res.status(500).send(`Server Error`);
logger.error(`Server Error in /download: ${err?.message ?? err}`);
res.status(500).send("Server Error");
}
}
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download progress updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function progressController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
@@ -44,14 +93,21 @@ function progressController(req, res) {
});
const sendProgress = percent => res.write(`data: ${percent}\n\n`);
listeners.push(sendProgress);
progressListeners.push(sendProgress);
req.on('close', () => {
listeners.splice(listeners.indexOf(sendProgress), 1);
progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
res.end();
});
}
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download speed updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function speedController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
@@ -68,6 +124,14 @@ function speedController(req, res) {
});
}
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download stage updates such as downloading,
* merging, extracting audio or embedding metadata.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function stageController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
@@ -84,6 +148,14 @@ function stageController(req, res) {
});
}
/**
* Cancels the currently running download process.
*
* Returns an error response if no download is active.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function cancelDownloadController(req, res) {
const cancelled = cancelDownload();
if (cancelled) {
@@ -95,6 +167,18 @@ function cancelDownloadController(req, res) {
}
}
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream playlist progress information to the client.
*
* Example:
* - Item 1 of 20
* - Item 12 of 20
* - Item 20 of 20
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function playlistInfoController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
@@ -103,10 +187,10 @@ function playlistInfoController(req, res) {
});
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
playlistInfoListeners.push(sendPlaylistInfo);
playlistListeners.push(sendPlaylistInfo);
req.on('close', () => {
playlistInfoListeners.splice(playlistInfoListeners.indexOf(sendPlaylistInfo), 1);
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
res.end();
});
}

View File

@@ -3,70 +3,127 @@ const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation.helpers");
async function infoController(req, res){
/**
* Handles metadata retrieval for a video or playlist.
*
* This controller:
* - Validates incoming URL (query or body)
* - Calls yt-dlp metadata extraction service
* - Detects playlist vs single video context
* - Applies fallback strategy if playlist extraction fails
* - Returns normalized parsed response (video or playlist)
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
async function infoController(req, res) {
const url = req.body.url || req.query.url; // POST et GET
// 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)
// If no url, non-string url or invalid url
if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("Invalid URL Or Missing");
logger.info(`/Info Request receive by the Info Controller. URL: ${url}`);
if (url.includes("&list") || url.includes("?list")) {
logger.info("Estimated Data Type: Playlist")
} else{
logger.info("Estimated Data Type: Video")
/**
* Basic validation:
* - URL must exist
* - Must be a string
* - Must pass custom URL validation
*/
if (!url || typeof url !== "string" || !isValidUrl(url)) {
return res.status(400).send("Invalid URL Or Missing");
}
try {
let data;
let fetchError = null;
logger.info(`Info request received. RAW URL: ${url}`);
logger.info(`Info request received. ENCODED: ${encodedUrl}`);
// Lightweight heuristic to detect playlist URLs. It works for Youtube.
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
logger.info(
isPlaylistUrl
? "Estimated Data Type: Playlist"
: "Estimated Data Type: Video"
);
try {
data = await fetchInfo(url);
} catch (err) {
fetchError = err;
// If it's a playlist URL and fetch failed, try fetching just the single video
if ((url.includes("&list") || url.includes("?list"))) {
logger.info(`Playlist fetch failed: ${err.message}. Retrying with single video...`);
// Remove the &list parameter
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}`);
throw err; // Throw the original error
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
throw err;
}
} else {
throw err;
}
} else {
throw err;
}
}
if (data._type === "playlist") {
/**
* Normalize response based on yt-dlp output type
*/
if (data._type === "playlist") {
const playlist = parsePlaylist(data);
logger.info(`Playlist : ${playlist.title} (${playlist.count} vidéos)`);
return res.json(playlist);
// If the playlist got only one video, show it as a video.
if (data.entries && data.entries.length === 1) {
} else{
const video = parseVideo(data);
logger.info(`Unique Video: ${video.title}`);
return res.json(video);
}
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`);
logger.error(
`Info controller error: ${err && err.message ? err.message : err}`
);
return res.status(500).send("Unable to fetch info");
}
}
module.exports = {infoController};
module.exports = { infoController };

View File

@@ -5,6 +5,26 @@ const { ffmpegPath, denoPath} = require("./path.helpers.js");
const { configFeatures } = require("../../config.js");
const { logger } = require("../logger.js");
/**
* Validates whether the provided codec is supported by the application.
*
* If the codec is supported, it is returned unchanged.
* Otherwise, a warning is logged and the default codec ("h264")
* is returned as a fallback.
*
* Supported codecs:
* - av01
* - vp9.2
* - vp9
* - h265
* - h264
* - vp8
* - h263
* - theora
*
* @param {string} codec - Codec identifier to validate.
* @returns {string} A valid codec identifier.
*/
function validateCodec(codec){
const validCodec = [
@@ -20,7 +40,29 @@ function validateCodec(codec){
}
}
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
/**
* Builds the complete yt-dlp argument list based on the
* application configuration and download options.
*
* The generated arguments include:
* - Browser cookie extraction
* - FFmpeg integration
* - Deno runtime configuration
* - Metadata and thumbnail embedding
* - Playlist handling
* - Video/audio format selection
* - Quality filtering
* - Output file destination
*
* @param {Object} options - Download configuration.
* @param {string} options.url - Video or playlist URL.
* @param {boolean} options.audioOnly - Whether to extract audio only.
* @param {string} options.quality - Requested quality preset.
* @param {string} options.outputFolder - Download destination folder.
*
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
*/
function buildYtDlpArgs({url, audioOnly, quality, outputFolder, isPlaylist}) {
logger.info("--- CONFIGURATION ---");
@@ -28,6 +70,7 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
logger.info(`CONFIG discordRPC: ${configFeatures.discordRPC}`);
logger.info(`CONFIG customTopBar: ${configFeatures.customTopBar}`);
logger.info(`CONFIG autoCheckInfo: ${configFeatures.autoCheckInfo}`);
logger.info(`CONFIG keepPlaylistOrder: ${configFeatures.keepPlaylistOrder}`);
logger.info(`CONFIG addThumbnail: ${configFeatures.addThumbnail}`);
logger.info(`CONFIG addMetadata: ${configFeatures.addMetadata}`);
logger.info(`CONFIG verboseLogs: ${configFeatures.verboseLogs}`);
@@ -75,7 +118,19 @@ function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
};
args.push("-f", qualityMap[quality] || "best");
args.push("-o", path.join(outputFolder, "%(title)s.%(ext)s"));
/**
* Default title output template
* @type {string}
*/
let outputTemplate = "%(title)s.%(ext)s";
// If it's a playlist, and we want to keep the playlist order. Add an index before the title.
if (isPlaylist && configFeatures.keepPlaylistOrder && configFeatures.autoDownloadPlaylist) {
outputTemplate = "%(playlist_index)02d - %(title)s.%(ext)s";
}
args.push("-o", path.join(outputFolder, outputTemplate));
args.push(url);
return args.filter(Boolean);

View File

@@ -4,12 +4,36 @@ const path = require("path");
const notify = require("./notify.helpers")
const { logger } = require("../logger");
/**
* Detects the first supported browser installed on the system.
*
* The detected browser is used by yt-dlp to import authentication
* cookies when required by certain platforms.
*
* Currently, Firefox is the only browser officially supported
* by the application.
*
* If no supported browser is found:
* - A warning is logged
* - A notification is displayed to the user
* - "firefox" is returned as a fallback to allow yt-dlp to
* handle the error gracefully without crashing the application
*
* @returns {string} The detected browser identifier.
*/
function getUserBrowser() {
const userProfile = os.homedir();
//const userProfile = os.homedir();
const firefoxPath = getFirefoxProfilePath();
// List of browsers supported by yt-dlp (Currently I can only guarantee Firefox, Sorry)
/**
* Browsers supported by yt-dlp cookie extraction.
*
* Only Firefox is currently enabled and tested.
* Additional browsers can be enabled in the future
* once compatibility has been verified.
*/
const browsers = [
{ name: "firefox", path: path.join(userProfile, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") },
{ name: "firefox", path: firefoxPath },
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
// { name: "brave", path: path.join(userProfile, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default") },
// { name: "edge", path: path.join(userProfile, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default") },
@@ -19,21 +43,60 @@ function getUserBrowser() {
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
];
// Search for an available browser
// Search for the first available browser profile.
for (const browser of browsers) {
if (fs.existsSync(browser.path)) {
if (browser.path && fs.existsSync(browser.path)) {
logger.info(`Browser found: ${browser.name}`);
return browser.name;
}
}
// No browser found - notify user
// No supported browser found => Notify User
logger.warn("No supported browser found on the system");
notify.notifyFirefoxBrowserMissing();
// Fallback: return "Firefox" to let YT-DLP manage error
// this avoid app crash
// If you somehow managed to live without Firefox and need help installing it. - Don't applied to my Linux chad
if (process.platform === "win32") {
notify.notifyFirefoxBrowserMissing();
}
// Fallback to Firefox and let yt-dlp handle the error gracefully.
// This prevents the application from crashing
return "firefox";
}
function getFirefoxProfilePath() {
const home = os.homedir();
if (process.platform === "win32") {
return path.join(
home,
"AppData",
"Roaming",
"Mozilla",
"Firefox",
"Profiles"
);
}
if (process.platform === "linux") {
return path.join(
"usr",
"bin",
"firefox"
);
}
if (process.platform === "darwin") {
return path.join(
home,
"Library",
"Application Support",
"Firefox",
"Profiles"
);
}
return null;
}
module.exports = getUserBrowser

View File

@@ -1,19 +1,65 @@
const { Notification, shell } = require("electron");
const { iconPaths } = require("./path.helpers");
const { logger } = require("../logger");
/**
* Set that is holding all notifications. Used to avoid Race Condition and notification crash
* @type {Set<any>}
*/
const activeNotifications = new Set();
/**
* Displays a system notification when a download completes successfully.
*
* If enabled, clicking the notification opens the download folder
* using the system file explorer.
*
* @param {string} folder - Path to the completed download folder.
* @param {boolean} notifyEnabled - Whether notifications are enabled in config.
*/
function notifyDownloadFinished(folder, notifyEnabled = true) {
if (!notifyEnabled) return;
if (!folder) return;
const notif = new Notification({
title: "Freedom Loader",
body: "Your download is complete, click here to open it.",
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();
}
/**
* Displays a notification when browser cookies cannot be extracted.
*
* This usually indicates:
* - User is not logged in to the browser
* - Browser profile is inaccessible
*
* Clicking the notification opens a tutorial link.
*/
function notifyCookiesBrowserError(){
const notif = new Notification({
title: "Cookies Error",
@@ -21,23 +67,51 @@ function notifyCookiesBrowserError(){
icon: iconPaths.error,
});
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"));
activeNotifications.add(notif);
notif.on("click", () => {
shell.openExternal("https://www.firefox.com/en-US/download/");
activeNotifications.delete(notif);
});
notif.on("close", () => {
activeNotifications.delete(notif);
});
notif.show();
}
function notifyFirefoxBrowserMissing(){
/**
* Displays a notification when Firefox is not detected
* on the user system.
*
* This is required for cookie extraction via yt-dlp.
*
* Clicking the notification opens an installation guide.
*/
function notifyFirefoxBrowserMissing() {
const notif = new Notification({
title: "Firefox Missing",
body: "Firefox was not found on your system. Click here to follow the installation guide",
icon: iconPaths.error,
});
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"))
activeNotifications.add(notif);
notif.on("click", () => {
shell.openExternal("https://www.firefox.com/en-US/download/");
activeNotifications.delete(notif);
});
notif.on("close", () => {
activeNotifications.delete(notif);
});
notif.show();
}
module.exports = {
notifyDownloadFinished,
notifyDownloadFinished,
notifyCookiesBrowserError,
notifyFirefoxBrowserMissing
notifyFirefoxBrowserMissing,
};

View File

@@ -1,27 +1,38 @@
/**
* Normalizes a video payload by attaching the resource type.
*
* @param {Object} data - Raw video metadata.
* @returns {Object} Normalized video object.
*/
function parseVideo(data) {
return {
type: "video",
...data
};
return {
type: "video",
...data
};
}
/**
* Transforms a raw playlist payload into a normalized structure
* containing playlist metadata and a simplified list of videos.
*
* @param {Object} data - Raw playlist metadata.
* @returns {Object} Normalized playlist object.
*/
function parsePlaylist(data) {
return {
type: "playlist",
title: data.title || data.id,
channel: data.uploader,
count: data.entries.length,
videos: (data.entries || []).map(v => ({
id: v.id,
title: v.title,
url: v.webpage_url,
duration: v.duration,
thumbnail: v.thumbnail,
uploader: v.uploader
}))
};
return {
type: "playlist",
title: data.title || data.id,
channel: data.uploader,
count: data.entries.length,
videos: (data.entries || []).map(v => ({
id: v.id,
title: v.title,
url: v.webpage_url,
duration: v.duration,
thumbnail: v.thumbnail,
uploader: v.uploader
}))
};
}
module.exports = { parseVideo, parsePlaylist };
module.exports = { parseVideo, parsePlaylist };

View File

@@ -1,110 +1,232 @@
const path = require("path");
const fs = require("fs");
const os = require("os");
const { app } = require("electron");
const config = require("../../config.js");
const { logger } = require("../logger.js");
/**
* Is the OS windows
*/
const isWindows = process.platform === 'win32'
// Centralized resource paths
const resourcesPath = config.localMode
? path.join(__dirname, "../../ressources")
/**
* On windows the binaries are name.exe on Linux/macOS no
*/
const ext = isWindows ? '.exe' : ''
/** Centralized resource paths */
const resourcesPath = config.devMode
? path.join(__dirname, "../../resources")
: process.resourcesPath;
// Default download folder (centralized)
const defaultDownloadFolder = path.join(os.homedir(), "Downloads", "Freedom Loader");
/** Default download folder (centralized) */
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader");
/**
* Current platform:
* win32 | linux | darwin
*/
const platform = process.platform;
/**
* Dev source tree uses platform-named subfolders (matches resources/binaries/{win-32,linux,darwin}).
* Packaged builds flatten everything directly under binaries/, since each
* platform build only ships its own binaries — no subfolder needed.
*/
const devPlatformFolder = {
win32: "win-32",
linux: "linux",
darwin: "darwin"
}[platform];
const binariesPath = config.devMode
? path.join(resourcesPath, "binaries", devPlatformFolder)
: path.join(resourcesPath, "binaries");
/**
* Runtime-resolved binary paths.
* These values differ between development and production builds.
*/
// Binary paths
let userYtDlp;
let ffmpegPath;
let denoPath;
const sourceYtDlp = path.join(resourcesPath, "binaries","yt-dlp.exe");
/** Source binary bundled inside application resources (used for first-time copy) */
const sourceYtDlp = path.join(
binariesPath,
`yt-dlp${ext}`
);
if (config.localMode) {
userYtDlp = path.join(__dirname, "../../ressources/yt-dlp.exe");
ffmpegPath = path.join(__dirname, "../../ressources/"); // <- has ffmpeg.exe and ffprobe.exe
denoPath = path.join(__dirname, "../../ressources/deno.exe");
/**
* Resolve binary locations depending on environment:
* - dev: local resources folder
* - prod: extracted userData + packaged resources
*/
if (config.devMode) {
userYtDlp = path.join(
binariesPath,
`yt-dlp${ext}`
);
ffmpegPath = path.join(
binariesPath,
`ffmpeg${ext}`
);
denoPath = path.join(
binariesPath,
`deno${ext}`
);
} else {
userYtDlp = path.join(app.getPath("userData"),"yt-dlp.exe");
ffmpegPath = path.join(resourcesPath, "binaries","ffmpeg.exe");
denoPath = path.join(resourcesPath, "binaries","deno.exe");
userYtDlp = path.join(
app.getPath("userData"),
`yt-dlp${ext}`
);
if (!fs.existsSync(userYtDlp)) {
ffmpegPath = path.join(
binariesPath,
`ffmpeg${ext}`
);
denoPath = path.join(
binariesPath,
`deno${ext}`
);
if (!fs.existsSync(userYtDlp) && fs.existsSync(sourceYtDlp)) {
fs.copyFileSync(sourceYtDlp, userYtDlp);
// Linux/macOS executable permission
if (!isWindows) {
try {
fs.chmodSync(userYtDlp, 0o755);
} catch (err) {
getLogger().warn(`Failed to chmod yt-dlp: ${err.message}`);
}
}
}
}
// Notification icon paths
/**
* Centralized notification and UI icon paths.
* Used by Electron notifications and UI components.
*/
const iconPaths = {
confirm: path.join(resourcesPath, "confirm-icon.png"),
error: path.join(resourcesPath, "error.png"),
app: path.join(resourcesPath, "app-icon.ico")
};
// Binary paths for verification
/**
* Static reference paths to bundled binaries.
* Used mainly for verification and diagnostics.
*/
const binaryPaths = {
ytDlp: path.join(resourcesPath, "binaries", "yt-dlp.exe"),
ffmpeg: path.join(resourcesPath, "binaries", "ffmpeg.exe"),
ffprobe: path.join(resourcesPath, "binaries", "ffprobe.exe"),
deno: path.join(resourcesPath, "binaries", "deno.exe")
ytDlp: path.join(
binariesPath,
`yt-dlp${ext}`
),
ffmpeg: path.join(
binariesPath,
`ffmpeg${ext}`
),
ffprobe: path.join(
binariesPath,
`ffprobe${ext}`
),
deno: path.join(
binariesPath,
`deno${ext}`
)
};
// Theme paths - themes stored in AppData to survive updates
/**
* Theme storage system:
* - default themes come from app resources
* - user themes are persisted in userData to survive updates
*/
const userThemesPath = path.join(app.getPath("userData"), "themes");
const defaultThemesSourcePath = path.join(resourcesPath, "theme");
/**
* Initializes user theme directory and ensures default themes exist.
*
* - Creates user themes folder if missing
* - Copies bundled themes from resources if not present
* - Applies permissive permissions for runtime modification
*/
function initUserThemes() {
try {
if (!fs.existsSync(userThemesPath)) {
fs.mkdirSync(userThemesPath, { recursive: true });
// Ensure folder is writable
fs.chmodSync(userThemesPath, 0o777);
logger.info("Created user themes directory");
getLogger().info("Created user themes directory");
}
// Copy default themes from resources to user directory if they don't exist
if (fs.existsSync(defaultThemesSourcePath)) {
const defaultThemes = fs.readdirSync(defaultThemesSourcePath);
for (const theme of defaultThemes) {
const srcPath = path.join(defaultThemesSourcePath, theme);
const destPath = path.join(userThemesPath, theme);
if (!fs.existsSync(destPath)) {
try {
const srcIsFile = !fs.statSync(srcPath).isDirectory();
if (fs.existsSync(destPath)) {
const destIsDir = fs.statSync(destPath).isDirectory();
if (srcIsFile && destIsDir) {
getLogger().warn(`Corrupted theme entry: ${theme}, fixing...`);
fs.rmSync(destPath, { recursive: true, force: true });
} else {
continue;
}
}
try {
if (srcIsFile) {
fs.copyFileSync(srcPath, destPath);
fs.chmodSync(destPath, 0o666);
} else {
copyDirectory(srcPath, destPath);
fs.chmodSync(destPath, 0o777);
logger.info(`Copied default theme: ${theme}`);
} catch (copyErr) {
logger.warn(`Failed to copy theme ${theme}: ${copyErr.message}. Theme folder will be created, add theme files manually.`);
try {
fs.mkdirSync(destPath, { recursive: true });
fs.chmodSync(destPath, 0o777);
} catch (mkdirErr) {
logger.warn(`Could not create theme directory ${theme}: ${mkdirErr.message}`);
}
}
getLogger().info(`Copied default theme: ${theme}`);
} catch (copyErr) {
getLogger().warn(`Failed to copy theme ${theme}: ${copyErr.message}`);
}
}
} else {
logger.warn(`Default themes source path not found: ${defaultThemesSourcePath}`);
getLogger().warn(`Default themes source path not found: ${defaultThemesSourcePath}`);
}
} catch (err) {
logger.error(`Failed to initialize user themes: ${err.message}`);
getLogger().error(`Failed to initialize user themes: ${err.message}`);
}
}
/**
* Recursively copies a directory and its contents.
*
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @throws Error If copy operation fails
*/
function copyDirectory(src, dest) {
try {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o777 });
fs.mkdirSync(dest, {
recursive: true,
mode: 0o777
});
}
const files = fs.readdirSync(src);
for (const file of files) {
const srcFile = path.join(src, file);
const destFile = path.join(dest, file);
if (fs.statSync(srcFile).isDirectory()) {
copyDirectory(srcFile, destFile);
} else {
@@ -116,8 +238,32 @@ function copyDirectory(src, dest) {
}
}
if (!userYtDlp){ logger.error("Missing YT-DLP")}
if (!ffmpegPath){ logger.error("Missing FFMPEG")}
if (!denoPath){ logger.error("Missing DENO")}
// Runtime validation of critical binaries
// if (!userYtDlp) {
// getLogger().error("Missing yt-dlp binary");
// }
module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes };
// if (!ffmpegPath) {
// getLogger().error("Missing ffmpeg binary");
// }
// if (!denoPath) {
// getLogger().error("Missing deno binary");
// }
function validateBinaries() {
if (!userYtDlp) getLogger().error("Missing yt-dlp binary");
if (!ffmpegPath) getLogger().error("Missing ffmpeg binary");
if (!denoPath) getLogger().error("Missing deno binary");
}
/**
* Helper to lazy load logger, avoid circular import
* @returns {winston.Logger}
*/
function getLogger() {
return require("../logger.js").logger;
}
module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes, isWindows, validateBinaries };

View File

@@ -1,14 +1,24 @@
const RateLimit = require("express-rate-limit");
/* Rate Limite */
const rateLimite = RateLimit({
/**
* Rate limiter middleware used to protect public endpoints
* against abusive or excessive requests.
*
* Restrictions:
* - 5 requests per IP address
* - 15-minute sliding window
*
* When the limit is exceeded, a HTTP 429 (Too Many Requests)
* response is returned.
*/
const rateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 100 requests per windowMs on the root path
max: 5,
message: "Too many requests, please try again later.",
statusCode: 429, // HTTP status code for "Too Many Requests"
statusCode: 429,
});
module.exports = {
rateLimite
rateLimit
}

View File

@@ -1,21 +1,43 @@
const path = require("path");
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
/**
* Validates whether the provided string is a well-formed URL.
*
* The validation relies on the native URL constructor and
* returns `true` only if the value can be successfully parsed.
*
* @param {string} url - URL to validate.
* @returns {boolean} True if the URL is valid, otherwise false.
*/
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validates whether a folder path is considered safe for
* file operations.
*
* Security checks:
* - Rejects empty or very short paths.
* - Resolves the absolute path before validation.
* - Prevents access to sensitive Windows system directories.
*
* @param {string} folder - Folder path to validate.
* @returns {boolean} True if the path is considered safe, otherwise false.
*/
function isSafePath(folder) {
if (!folder || folder.length < 3) return false;
const unsafe = ["System32", "\\Windows"];
const resolved = path.resolve(folder);
return !unsafe.some(u => resolved.includes(u));
}
module.exports = { isValidUrl, isSafePath };
module.exports = { isValidUrl, isSafePath };

View File

@@ -4,9 +4,11 @@ const fs = require("fs");
const path = require("path");
const os = require("os");
const config = require("../config");
const {isWindows} = require("./helpers/path.helpers");
const { logSystemInfo } = require("./sysinfo");
// Logs folder in Windows
const logDir = path.join(os.homedir(), "AppData", "Local", "FreedomLoader", "logs");
const logDir = isWindows ? path.join(os.homedir(), "AppData", "Local", "FreedomLoader", "logs") : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "FreedomLoader", "logs");
// Create "Logs" folder if needed
try {
@@ -20,10 +22,42 @@ const logFormat = format.combine(
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
);
// Logger configuration
/**
* Format used by the saved file
* @type {Format}
*/
const fileFormat = format.combine(
format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}),
format.errors({stack: true}), // Capture la stack trace complète des erreurs
format.printf(({timestamp, level, message, stack}) => {
return `${timestamp} | ${level.toUpperCase()} | ${stack || message}`;
})
);
/**
* Formated used by the IDE/Console
* @type {Format}
*/
const consoleFormat = format.combine(
format.colorize({all: true}),
format.timestamp({format: "HH:mm:ss"}),
format.errors({stack: true}),
format.printf(({timestamp, level, message, stack}) => {
return `[${timestamp}] ${level}: ${stack || message}`;
})
);
/**
* Logger Instance with differents types
* - info
* - error
* - warn
*
* You need to specify the text that need to be displayed
* @param string
*/
const logger = createLogger({
level: "info",
format: logFormat,
transports: [
new DailyRotateFile({
dirname: logDir,
@@ -31,20 +65,28 @@ const logger = createLogger({
datePattern: "YYYY-MM-DD",
zippedArchive: false,
maxFiles: "7d",
format: logFormat,
format: fileFormat,
options: { flags: "a" },
}),
new transports.Console({
format: logFormat,
format: consoleFormat,
}),
],
});
function logSessionStart() {
/**
* Start Log Session
*/
function logSessionStart(logDir, downloadPath) {
logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
logger.info("============================================================")
logger.info(`Application Version: ${config.version}`)
if (typeof logSystemInfo === 'function') logSystemInfo(logger, logDir, downloadPath);
}
/**
* Stop Log Session
*/
function logSessionEnd() {
logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
}

View File

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

View File

@@ -2,7 +2,7 @@ const express = require("express");
const path = require("path");
const { logger, logSessionEnd } = require("./logger");
const config = require("../config");
const { rateLimite } = require("./helpers/rateLimit.helpers");
const { rateLimit } = require("./helpers/rateLimit.helpers");
const app = express();
@@ -13,11 +13,21 @@ app.use(express.static(path.join(__dirname, "../public")));
// Routes
app.use("/download", require("./routes/download.route"));
app.use("/info", require("./routes/info.route"));
app.get("/", rateLimite, (req, res) => {
app.use("/info", require("./routes/info.route"));
// Interface
app.get("/", rateLimit, (req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html"));
});
/**
* Initializes and starts the Express HTTP server.
*
* - Binds the server to `config.applicationPort`
* - Resolves with the HTTP server instance on successful startup
* - Rejects on server binding or runtime errors
* - Registers SIGINT/SIGTERM handlers for graceful shutdown
*/
async function startServer() {
return new Promise((resolve, reject) => {
const server = app.listen(config.applicationPort, () => {
@@ -30,6 +40,12 @@ async function startServer() {
reject(err);
});
/**
* Clean exit function
* - Stop Log
* - Stop Express Server
* - Stop Electron App
*/
const gracefulExit = () => {
logSessionEnd();
server.close(() => {

View File

@@ -6,15 +6,47 @@ const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
const notify = require("../helpers/notify.helpers");
const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers");
const { configFeatures } = require("../../config.js");
const {reloadFeatures} = require("../../config");
/**
* Reference to the currently running yt-dlp process.
*
* Used to support download cancellation and lifecycle
* management.
*
* @type {import("child_process").ChildProcess|null}
*/
let currentDownloadProcess = null;
// Détecte si une URL est une playlist
/**
* Determines whether a URL targets a playlist.
*
* The detection is based on the presence of the YouTube
* playlist query parameter (`list`).
*
* @param {string} url - URL to inspect.
* @returns {boolean} True if the URL appears to be a playlist.
*/
function isPlaylistUrl(url) {
return url.includes("?list=") || url.includes("&list=");
}
/**
* Creates a dedicated download folder for a playlist.
*
* The playlist title is sanitized to ensure it can be safely
* used as a filesystem path. If the target folder already
* exists, an incremented suffix is appended until a unique
* folder name is found.
*
* If folder creation fails, a fallback folder named
* "Untitled Playlist X" is generated.
*
* @param {string} basePath - Parent download directory.
* @param {string} playlistTitle - Playlist title returned by yt-dlp.
* @returns {string} Absolute path of the created playlist folder.
* @throws {Error} If no valid folder can be created.
*/
function createPlaylistFolder(basePath, playlistTitle) {
const sanitizedTitle = playlistTitle
.replace(/[<>:"|?*]/g, '')
@@ -74,10 +106,39 @@ function createPlaylistFolder(basePath, playlistTitle) {
}
}
/**
* Starts a yt-dlp download process and monitors its progress.
*
* Responsibilities:
* - Validates and prepares the output directory
* - Creates playlist folders when enabled
* - Builds yt-dlp command arguments
* - Spawns the yt-dlp process
* - Parses progress, speed and stage information
* - Notifies listeners about download events
* - Handles download completion and failures
*
* @param {Object} options - Download options.
* @param {string} options.url - Video or playlist URL.
* @param {string} [options.outputFolder] - Destination folder.
* @param {string} [options.playlistTitle] - Playlist title.
*
* @param {Function[]} listeners - Progress listeners.
* @param {Function[]} speedListeners - Download speed listeners.
* @param {Function[]} stageListeners - Processing stage listeners.
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
*
* @returns {Promise<string>}
* Resolves with the final output folder path once the
* download has completed successfully.
*/
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
return new Promise((resolve, reject) => {
logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
const userConfig = reloadFeatures();
logger.info(`CONFIG createPlaylistFolders: ${userConfig.createPlaylistFolders}`);
let outputFolder = options.outputFolder || defaultDownloadFolder;
@@ -100,8 +161,8 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
// Détecte si c'est une playlist et crée un dossier approprié
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
if (isPlaylist && configFeatures.createPlaylistFolders) {
if (isPlaylist && userConfig.createPlaylistFolders) {
try {
const playlistName = options.playlistTitle || "Untitled Playlist";
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
@@ -110,11 +171,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
logger.error(`Failed to create playlist folder: ${err.message}`);
return reject(err);
}
} else if (isPlaylist && !configFeatures.createPlaylistFolders) {
} else if (isPlaylist && !userConfig.createPlaylistFolders) {
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
}
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder });
const args = buildYtDlpArgs({
...options,
outputFolder: safeOutputFolder,
isPlaylist
});
logger.info(`[yt-dlp args] ${args.join(" ")}`);
const child = execFile(userYtDlp, args);
@@ -191,6 +257,16 @@ function fetchDownload(options, listeners, speedListeners, stageListeners, playl
});
}
/**
* Cancels the currently running yt-dlp process.
*
* Attempts to terminate the entire process tree to ensure
* that yt-dlp and any spawned FFmpeg processes are stopped.
*
* @returns {boolean}
* True if a download process was cancelled,
* otherwise false.
*/
function cancelDownload() {
if (currentDownloadProcess) {
logger.info("Cancelling download and killing all child processes...");

View File

@@ -3,8 +3,30 @@ const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const getUserBrowser = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger");
/**
* Retrieves metadata for a video or playlist using yt-dlp.
*
* The command executes yt-dlp in JSON mode and returns the
* parsed response object without downloading any media.
*
* Features:
* - Supports videos and playlists
* - Imports browser cookies when available
* - Uses Deno for JavaScript extraction
* - Applies a 30-second timeout
* - Limits response size to 10 MB
*
* @param {string} url - Video or playlist URL to inspect.
* @returns {Promise<Object>} Resolves with the metadata returned by yt-dlp.
*
*/
function fetchInfo(url) {
const args = [
/**
* Base yt-dlp arguments used to retrieve metadata
* without downloading media files.
*/
const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`,
"--dump-single-json",
"--ignore-errors",
@@ -16,9 +38,9 @@ function fetchInfo(url) {
return new Promise((resolve, reject) => {
execFile(userYtDlp,[...args, url],
execFile(userYtDlp,[...metadataArgs , url],
{ timeout: 30_000, // 30s, if more timeout
maxBuffer: 10 * 1024 * 1024 }, // 10MO max response (Default: 200ko)
maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
(error, stdout, stderr) => {

142
server/sysinfo.js Normal file
View File

@@ -0,0 +1,142 @@
const os = require("os");
const fs = require("fs");
const { execSync } = require("child_process");
const { app } = require("electron");
/**
* Reads /etc/os-release to get the Linux distribution name
*/
function getLinuxDistro() {
try {
const content = fs.readFileSync("/etc/os-release", "utf-8");
const pretty = content.match(/^PRETTY_NAME="?(.+?)"?$/m);
if (pretty) return pretty[1];
const name = content.match(/^NAME="?(.+?)"?$/m);
return name ? name[1] : "Unknown Linux";
} catch {
return "Unknown Linux";
}
}
/**
* Reads the package-type file written by electron-builder
* to determine how the app was distributed (rpm, snap, AppImage, deb...)
*/
function getPackageType() {
try {
const pkgTypePath = require("path").join(process.resourcesPath, "package-type");
if (fs.existsSync(pkgTypePath)) {
return fs.readFileSync(pkgTypePath, "utf-8").trim();
}
} catch { /* ignore */ }
return "unknown";
}
/**
* Checks if Firefox is installed on the current platform
*/
function detectFirefox() {
const platform = process.platform;
if (platform === "win32") {
const paths = [
"C:\\Program Files\\Mozilla Firefox\\firefox.exe",
"C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe",
`${process.env.LOCALAPPDATA}\\Mozilla Firefox\\firefox.exe`,
];
return paths.some(p => fs.existsSync(p));
}
if (platform === "linux") {
const paths = [
"/usr/bin/firefox",
"/usr/bin/firefox-esr",
"/usr/local/bin/firefox",
"/snap/bin/firefox",
"/var/lib/flatpak/exports/bin/org.mozilla.firefox",
`${os.homedir()}/.local/share/flatpak/exports/bin/org.mozilla.firefox`,
];
if (paths.some(p => fs.existsSync(p))) return true;
try { execSync("which firefox", { stdio: "ignore" }); return true; } catch { return false; }
}
return false;
}
/**
* Builds the system info object logged at startup
*
* @param {string} logDir - path to the log directory
* @param {string} downloadPath - current configured download path
*/
function getSystemInfo(logDir, downloadPath) {
const platform = process.platform;
const cpus = os.cpus();
const totalMem = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
const freeMem = (os.freemem() / 1024 / 1024 / 1024).toFixed(1);
let platformLabel;
if (platform === "win32") {
platformLabel = `Windows (${os.release()})`;
} else if (platform === "linux") {
platformLabel = `Linux — ${getLinuxDistro()}`;
} else {
platformLabel = `${platform} ${os.release()} (unsupported)`;
}
const info = {
"Platform": platformLabel,
"Architecture": os.arch(),
"CPU": cpus.length ? `${cpus[0].model.trim()} (${cpus.length} cores)` : "Unknown",
"Memory": `${freeMem} GB free / ${totalMem} GB total`,
"Locale": app.getLocale(),
"Electron": process.versions.electron,
"Node": process.versions.node,
"Packaged": String(app.isPackaged),
"Package type": app.isPackaged ? getPackageType() : "dev",
"Firefox present": detectFirefox() ? "Yes" : "No — cookie features unavailable",
"Log directory": logDir,
"Download path": downloadPath,
};
// Linux-specific extras
if (platform === "linux") {
const sessionType = process.env.XDG_SESSION_TYPE || "unknown";
const waylandDisplay = process.env.WAYLAND_DISPLAY;
const x11Display = process.env.DISPLAY;
info["Display server"] = waylandDisplay
? `Wayland (${waylandDisplay})`
: x11Display
? `X11 (${x11Display})`
: sessionType !== "unknown"
? sessionType
: "unknown";
info["Desktop env"] = process.env.XDG_CURRENT_DESKTOP || process.env.DESKTOP_SESSION || "unknown";
}
return info;
}
/**
* Logs system info at application startup
*
* @param {object} logger - winston logger instance
* @param {string} logDir - path to the log directory
* @param {string} downloadPath - current configured download path
*/
function logSystemInfo(logger, logDir, downloadPath) {
const info = getSystemInfo(logDir, downloadPath);
const maxKeyLen = Math.max(...Object.keys(info).map(k => k.length));
logger.info("=".repeat(60));
logger.info("SYSTEM INFO");
logger.info("=".repeat(60));
for (const [key, val] of Object.entries(info)) {
logger.info(` ${key.padEnd(maxKeyLen)} : ${val}`);
}
logger.info("=".repeat(60));
}
module.exports = { logSystemInfo };

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

BIN
theme/dark.zip Normal file

Binary file not shown.

BIN
theme/light.zip Normal file

Binary file not shown.