1
1
mirror of https://github.com/neosubhamoy/neodlp.git synced 2026-03-22 20:35:49 +05:30

44 Commits

50 changed files with 2637 additions and 886 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
.github/images/flathub/downloader.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
.github/images/flathub/settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,24 +1,14 @@
### ✨ Changelog ### ✨ Changelog
- Added support for selective-batch/full-playlist download - Added 'Health Check' section in app info
- Added support for selecting multiple audio streams on combine mode - Added Homebrew package for macOS
- Added support for embedding original auto-generated subtitles - Added initial compatibility with Flatpak for Linux
- Added option to crop thubnails to square (1:1) before embedding - Other minor fixes and improvements
- Added 'errored' download state (to better identify errored downloads, which you can retry later)
- Added app interface color scheme options on appearance settings
- Added app info page under settings
- Added copy/clear log buttons in log viewer
- Added sponsorblock 'hook' category
- Fixed sidebar state not persisting on app re-start
- Fixed linux native (deb/rpm) installation downloading appimage update
- Bumped up shadcn/ui to v3.5 and lots of under the hood ui improvements
- Optimized database and backend performance
- Lots of other fixes and improvements
### 📝 Notes ### 📝 Notes
> [!CAUTION] > [!CAUTION]
> This update introduces few breaking changes! Users are adviced to complete/cancel all paused downloads before updating to this version, otherwise paused downloads may not resume properly or re-start from the begining. > Users are always adviced to complete/cancel all paused downloads before updating to a newer version, otherwise paused downloads may not resume properly and re-start from the begining.
> [!WARNING] > [!WARNING]
> Linux users make sure `yt-dlp` and `deno` is not installed in your distro (otherwise you will get package installation conflict). Don't worry, You can still use yt-dlp cli as before (the only difference is that now it will be installed and auto-updated by neo-dlp, which You can also disable from neo-dlp Settings if you don't want to auto-update yt-dlp) > Linux users make sure `yt-dlp` and `deno` is not installed in your distro (otherwise you will get package installation conflict). Don't worry, You can still use yt-dlp cli as before (the only difference is that now it will be installed and auto-updated by neo-dlp, which You can also disable from neo-dlp Settings if you don't want to auto-update yt-dlp)
@@ -29,13 +19,13 @@
### 📦 Shipped Binaries ### 📦 Shipped Binaries
| yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno | | yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno | bgutil-pot-rs |
| :---- | :---- | :---- | :---- | :---- | | :---- | :---- | :---- | :---- | :---- | :---- |
| v2026.01.19.233146 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.6.5 | | v2026.03.02.233544 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.7.1 | v0.7.2-1.3.0 |
> ‼️ Linux builds (deb, rpm) does not ships with `ffmpeg` and `ffprobe` (though it will be auto installed as a dependency by your package manager, if you are on fedora make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package) > ‼️ Linux builds (deb, rpm) does not ships with `ffmpeg` and `ffprobe` (though it will be auto installed as a dependency by your package manager, if you are on fedora make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package)
> ‼️ MacOS builds (dmg, app) does not ships with `aria2c`, If you want to use [aria2](https://formulae.brew.sh/formula/aria2) install it via [homebrew](https://brew.sh) > ‼️ MacOS builds (dmg, app) does not ships with `aria2c`, If you want to use [aria2](https://formulae.brew.sh/formula/aria2) install it via [homebrew](https://brew.sh) (though it will be auto installed as a dependency if you install neodlp via homebrew)
### ⬇️ Download Section ### ⬇️ Download Section

View File

@@ -2,7 +2,7 @@
# NeoDLP - Neo Downloader Plus # NeoDLP - Neo Downloader Plus
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration
[![github release](https://img.shields.io/github/v/release/neosubhamoy/neodlp?color=lime-green&style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases/latest) [![github release](https://img.shields.io/github/v/release/neosubhamoy/neodlp?color=lime-green&style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases/latest)
[![github downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total?style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases) [![github downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total?style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases)
@@ -18,13 +18,15 @@ Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Int
## ✨ Highlighted Features ## ✨ Highlighted Features
- Download Video/Audio from popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)) - Download Video/Audio from thousands of popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md))
- Fully Configured YT-DLP Environment Out-of-the-Box (with JS Runtime, PO Token Server, Real-Time Logs etc.)
- Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.) - Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.)
- Supports both Video and Playlist download - Supports both Video and Playlist/Batch download
- Supports Combining Video, Audio streams of your choice - Supports Combining Video, Audio streams of your choice
- Supports Multi-Lingual Subtitle/Caption (CC) embeding - Supports Multi-Lingual Subtitle/Caption (CC) embeding
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.) - Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
- SponsorBlock support (mark/remove video segments) - SponsorBlock support (mark/remove video segments)
- Aria2 support (for blazing fast downloads)
- Network controls (proxy, rate limit etc.) - Network controls (proxy, rate limit etc.)
- Highly customizable and many more...😉 - Highly customizable and many more...😉
@@ -58,6 +60,7 @@ After installing the extension you can do the following directly from the browse
- [FFmpeg & FFprobe](https://www.ffmpeg.org) [LGPLv2.1+] - Used for video/audio post-processing - [FFmpeg & FFprobe](https://www.ffmpeg.org) [LGPLv2.1+] - Used for video/audio post-processing
- [Aria2](https://aria2.github.io) [GPLv2+] - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds) - [Aria2](https://aria2.github.io) [GPLv2+] - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds)
- [Deno](https://deno.com) [MIT] - Provides sandboxed javascript runtime environment for yt-dlp (Required for YT downloads, as per the new yt-dlp [announcement](https://github.com/yt-dlp/yt-dlp/issues/14404)) - [Deno](https://deno.com) [MIT] - Provides sandboxed javascript runtime environment for yt-dlp (Required for YT downloads, as per the new yt-dlp [announcement](https://github.com/yt-dlp/yt-dlp/issues/14404))
- [BgUtils POT Provider (Rust)](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) [GPLv3+] - Provides PO (Proof-of-Origin) Token for YT downloads
## System Pre-Requirements ## System Pre-Requirements
@@ -80,9 +83,10 @@ After installing the extension you can do the following directly from the browse
| Platform (OS) | Distribution Channel | Installation Command / Instruction | | Platform (OS) | Distribution Channel | Installation Command / Instruction |
| :---- | :---- | :---- | | :---- | :---- | :---- |
| Windows x86_64 / ARM64 | WinGet | `winget install neosubhamoy.neodlp` | | Windows x86_64 / ARM64 | WinGet | `winget install neosubhamoy.neodlp` |
| MacOS x86_64 / ARM64 | Homebrew | `brew install neosubhamoy/tap/neodlp` |
| MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/macos_installer.sh \| bash` | | MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/macos_installer.sh \| bash` |
| Linux x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/linux_installer.sh \| bash` | | Linux x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/linux_installer.sh \| bash` |
| Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` | | Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` or `paru -S neodlp` |
## 🧪 Package Testing Status ## 🧪 Package Testing Status
@@ -195,6 +199,7 @@ Noticed any Bug? or Want to give us some suggetions? Always feel free to let us
- NeoDLP is made possible by the joint efforts of [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://www.ffmpeg.org). Lots of NeoDLP features are actually powered by these tools under the hood! So huge thanks to all the developers/contributers for making these great tools! 🙏 - NeoDLP is made possible by the joint efforts of [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://www.ffmpeg.org). Lots of NeoDLP features are actually powered by these tools under the hood! So huge thanks to all the developers/contributers for making these great tools! 🙏
- NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02) - NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02)
- Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds) - Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds)
- NeoDLP's 'POT Server' is based on [@jim60105's Rust Implementation](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) of [Brainicism/bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
## ⚖️ License and Usage ## ⚖️ License and Usage

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=NeoDLP
Comment=Modern video/audio downloader based on yt-dlp with browser integration.
Icon=com.neosubhamoy.neodlp
Exec=neodlp
Terminal=false
Categories=Utility;
Keywords=neodlp;downloader;yt-dlp-gui;

View File

@@ -0,0 +1,46 @@
<?xml version='1.0' encoding='UTF-8'?>
<component type="desktop-application">
<id>com.neosubhamoy.neodlp</id>
<name>NeoDLP</name>
<summary>Modern video/audio downloader based on yt-dlp with browser integration</summary>
<developer id="com.neosubhamoy">
<name>Subhamoy Biswas</name>
</developer>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://neodlp.neosubhamoy.com</url>
<url type="vcs-browser">https://github.com/neosubhamoy/neodlp</url>
<url type="bugtracker">https://github.com/neosubhamoy/neodlp/issues</url>
<description>
<p>
NeoDLP is a cross-platform desktop application designed for downloading videos and audio from various online sources based on yt-dlp.
It offers modern user interface, lots of customization options and seamless browser integration.
</p>
</description>
<launchable type="desktop-id">com.neosubhamoy.neodlp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/downloader.png</image>
<caption>Downloader page of NeoDLP</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/completed-downloads.png</image>
<caption>Completed downloads page of NeoDLP</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/ongoing-downloads.png</image>
<caption>Ongoing downloads page of NeoDLP</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/settings.png</image>
<caption>Settings page of NeoDLP</caption>
</screenshot>
</screenshots>
<content_rating type="oars-1.1" />
<releases>
<release version="0.4.2" date="2026-03-03">
<url type="details">https://github.com/neosubhamoy/neodlp/releases/tag/v0.4.2</url>
</release>
</releases>
</component>

698
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{ {
"name": "neodlp", "name": "neodlp",
"private": true, "private": true,
"version": "0.4.0", "version": "0.4.2",
"description": "Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration", "description": "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -21,9 +21,9 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/devtools-vite": "^0.5.1", "@tanstack/devtools-vite": "^0.5.2",
"@tanstack/react-devtools": "^0.9.5", "@tanstack/react-devtools": "^0.9.6",
"@tanstack/react-pacer": "^0.19.4", "@tanstack/react-pacer": "^0.20.0",
"@tanstack/react-pacer-devtools": "^0.5.2", "@tanstack/react-pacer-devtools": "^0.5.2",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-devtools": "^5.91.3",
@@ -41,30 +41,30 @@
"@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-updater": "^2.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.576.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.2", "react-resizable-panels": "^4.7.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"ulid": "^3.0.2", "ulid": "^3.0.2",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2.10.0", "@tauri-apps/cli": "^2.10.0",
"@types/node": "^25.2.3", "@types/node": "^25.3.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"postcss": "^8.5.6", "postcss": "^8.5.8",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1"

View File

@@ -20,6 +20,7 @@ const versions = {
'ffmpeg-ffprobe': 'latest', 'ffmpeg-ffprobe': 'latest',
'deno': 'latest', 'deno': 'latest',
'aria2c': '1.37.0', 'aria2c': '1.37.0',
'neodlp-pot': '0.7.2'
}; };
const binaries = { const binaries = {
@@ -145,10 +146,54 @@ const binaries = {
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl') path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl')
] ]
}, },
// {
// name: 'ffmpeg-universal-apple-darwin',
// platform: 'darwin',
// url: `https://evermeet.cx/ffmpeg/get/zip`,
// src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
// dest: null,
// archive: {
// type: 'zip',
// binSrc: [
// path.join(downloadDir, 'ffmpeg'),
// path.join(downloadDir, 'ffmpeg')
// ],
// binDest: [
// path.join(binDir, 'ffmpeg-x86_64-apple-darwin'),
// path.join(binDir, 'ffmpeg-aarch64-apple-darwin')
// ]
// },
// cleanup: [
// path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
// path.join(downloadDir, 'ffmpeg')
// ]
// },
// {
// name: 'ffprobe-universal-apple-darwin',
// platform: 'darwin',
// url: `https://evermeet.cx/ffmpeg/get/ffprobe/zip`,
// src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
// dest: null,
// archive: {
// type: 'zip',
// binSrc: [
// path.join(downloadDir, 'ffprobe'),
// path.join(downloadDir, 'ffprobe')
// ],
// binDest: [
// path.join(binDir, 'ffprobe-x86_64-apple-darwin'),
// path.join(binDir, 'ffprobe-aarch64-apple-darwin')
// ]
// },
// cleanup: [
// path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
// path.join(downloadDir, 'ffprobe')
// ]
// },
{ {
name: 'ffmpeg-universal-apple-darwin', name: 'ffmpeg-universal-apple-darwin',
platform: 'darwin', platform: 'darwin',
url: `https://evermeet.cx/ffmpeg/get/zip`, url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-universal-apple-darwin.zip`,
src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'), src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
dest: null, dest: null,
archive: { archive: {
@@ -170,7 +215,7 @@ const binaries = {
{ {
name: 'ffprobe-universal-apple-darwin', name: 'ffprobe-universal-apple-darwin',
platform: 'darwin', platform: 'darwin',
url: `https://evermeet.cx/ffmpeg/get/ffprobe/zip`, url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffprobe-universal-apple-darwin.zip`,
src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'), src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
dest: null, dest: null,
archive: { archive: {
@@ -353,6 +398,73 @@ const binaries = {
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`) path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`)
] ]
} }
],
'neodlp-pot': [
{
name: 'neodlp-pot-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-windows-x86_64.exe`,
src: path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-pc-windows-msvc.exe')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe')
]
},
{
name: 'neodlp-pot-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-x86_64`,
src: path.join(downloadDir, 'bgutil-pot-linux-x86_64'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-linux-x86_64')
]
},
{
name: 'neodlp-pot-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-aarch64`,
src: path.join(downloadDir, 'bgutil-pot-linux-aarch64'),
dest: [
path.join(binDir, 'neodlp-pot-aarch64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-linux-aarch64')
]
},
{
name: 'neodlp-pot-x86_64-apple-darwin',
platform: 'darwin',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-x86_64`,
src: path.join(downloadDir, 'bgutil-pot-macos-x86_64'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-apple-darwin')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-macos-x86_64')
]
},
{
name: 'neodlp-pot-aarch64-apple-darwin',
platform: 'darwin',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-aarch64`,
src: path.join(downloadDir, 'bgutil-pot-macos-aarch64'),
dest: [
path.join(binDir, 'neodlp-pot-aarch64-apple-darwin')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-macos-aarch64')
]
}
] ]
} }

458
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "neodlp" name = "neodlp"
version = "0.4.0" version = "0.4.2"
description = "Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration" description = "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration"
authors = ["neosubhamoy <hey@neosubhamoy.com>"] authors = ["neosubhamoy <hey@neosubhamoy.com>"]
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"

View File

@@ -31,7 +31,7 @@
"notification:default", "notification:default",
"log:default", "log:default",
{ {
"identifier": "opener:allow-open-path", "identifier": "fs:scope",
"allow": [ "allow": [
{ {
"path": "**" "path": "**"
@@ -39,7 +39,7 @@
] ]
}, },
{ {
"identifier": "fs:scope", "identifier": "opener:allow-open-path",
"allow": [ "allow": [
{ {
"path": "**" "path": "**"

View File

@@ -35,6 +35,11 @@
"args": true, "args": true,
"sidecar": true "sidecar": true
}, },
{
"name": "binaries/neodlp-pot",
"args": true,
"sidecar": true
},
{ {
"name": "ffmpeg", "name": "ffmpeg",
"cmd": "ffmpeg", "cmd": "ffmpeg",
@@ -45,6 +50,11 @@
"cmd": "aria2c", "cmd": "aria2c",
"args": true "args": true
}, },
{
"name": "deno",
"cmd": "deno",
"args": true
},
{ {
"name": "pkexec", "name": "pkexec",
"cmd": "pkexec", "cmd": "pkexec",
@@ -54,6 +64,11 @@
"name": "powershell", "name": "powershell",
"cmd": "powershell", "cmd": "powershell",
"args": true "args": true
},
{
"name": "sh",
"cmd": "sh",
"args": true
} }
] ]
}, },
@@ -85,6 +100,11 @@
"args": true, "args": true,
"sidecar": true "sidecar": true
}, },
{
"name": "binaries/neodlp-pot",
"args": true,
"sidecar": true
},
{ {
"name": "ffmpeg", "name": "ffmpeg",
"cmd": "ffmpeg", "cmd": "ffmpeg",
@@ -94,6 +114,11 @@
"name": "aria2c", "name": "aria2c",
"cmd": "aria2c", "cmd": "aria2c",
"args": true "args": true
},
{
"name": "deno",
"cmd": "deno",
"args": true
} }
] ]
} }

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
__version__ = '1.3.0'
import abc
import json
from yt_dlp.extractor.youtube.pot.provider import (
ExternalRequestFeature,
PoTokenContext,
PoTokenProvider,
PoTokenProviderRejectedRequest,
)
from yt_dlp.extractor.youtube.pot.utils import WEBPO_CLIENTS
from yt_dlp.utils import js_to_json
from yt_dlp.utils.traversal import traverse_obj
class BgUtilPTPBase(PoTokenProvider, abc.ABC):
PROVIDER_VERSION = __version__
BUG_REPORT_LOCATION = (
'https://github.com/jim60105/bgutil-ytdlp-pot-provider/issues'
)
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
ExternalRequestFeature.PROXY_SCHEME_HTTP,
ExternalRequestFeature.PROXY_SCHEME_HTTPS,
ExternalRequestFeature.PROXY_SCHEME_SOCKS4,
ExternalRequestFeature.PROXY_SCHEME_SOCKS4A,
ExternalRequestFeature.PROXY_SCHEME_SOCKS5,
ExternalRequestFeature.PROXY_SCHEME_SOCKS5H,
ExternalRequestFeature.SOURCE_ADDRESS,
ExternalRequestFeature.DISABLE_TLS_VERIFICATION,
)
_SUPPORTED_CLIENTS = WEBPO_CLIENTS
_SUPPORTED_CONTEXTS = (
PoTokenContext.GVS,
PoTokenContext.PLAYER,
PoTokenContext.SUBS,
)
_GETPOT_TIMEOUT = 20.0
_GET_SERVER_VSN_TIMEOUT = 5.0
_MIN_NODE_VSN = (18, 0, 0)
def _info_and_raise(self, msg, raise_from=None):
self.logger.info(msg)
raise PoTokenProviderRejectedRequest(msg) from raise_from
def _warn_and_raise(self, msg, once=True, raise_from=None):
self.logger.warning(msg, once=once)
raise PoTokenProviderRejectedRequest(msg) from raise_from
def _get_attestation(self, webpage: str | None):
if not webpage:
return None
raw_cd = (
traverse_obj(
self.ie._search_regex(
r'''(?sx)window\s*\.\s*ytAtN\s*\(\s*
(?P<js>\{.+?}\s*)
\s*\)\s*;''',
webpage,
'ytAtN challenge',
default=None),
({js_to_json}, {json.loads}, 'R'))
or traverse_obj(
self.ie._search_regex(
r'''(?sx)window\.ytAtR\s*=\s*(?P<raw_cd>(?P<q>['"])
(?:
\\.|
(?!(?P=q)).
)*
(?P=q))\s*;''',
webpage,
'ytAtR challenge',
default=None),
({js_to_json}, {json.loads})))
if att_txt := traverse_obj(raw_cd, ({json.loads}, 'bgChallenge')):
return att_txt
self.logger.warning('Failed to extract initial attestation from the webpage')
return None
__all__ = ['__version__']

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import functools
import json
import os.path
import shutil
import subprocess
from yt_dlp.extractor.youtube.pot.provider import (
PoTokenProviderError,
PoTokenRequest,
PoTokenResponse,
register_preference,
register_provider,
)
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
from yt_dlp.utils import Popen
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
@register_provider
class BgUtilCliPTP(BgUtilPTPBase):
PROVIDER_NAME = 'bgutil:cli'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._check_cli = functools.cache(self._check_cli_impl)
@functools.cached_property
def _cli_path(self):
cli_path = self._configuration_arg(
'cli_path', casesense=True, default=[None])[0]
if cli_path:
return os.path.expandvars(cli_path)
# check deprecated arg
deprecated_cli_path = self.ie._configuration_arg(
ie_key='youtube', key='getpot_bgutil_script', default=[None])[0]
if deprecated_cli_path:
self._warn_and_raise(
"'youtube:getpot_bgutil_script' extractor arg is deprecated, "
"use 'youtubepot-bgutilcli:cli_path' instead")
# default if no arg was passed
# First, try to find the executable in PATH
if self._get_executable_path('bgutil-pot'):
self.logger.debug('Found bgutil-pot in PATH')
return 'bgutil-pot'
# Then check common file locations
file_paths = [
os.path.join(
os.getcwd(), 'target', 'debug', 'bgutil-pot'
),
os.path.join(
os.getcwd(), 'target', 'release', 'bgutil-pot'
),
os.path.expanduser(
'~/bgutil-ytdlp-pot-provider/target/debug/bgutil-pot'
),
os.path.expanduser(
'~/bgutil-ytdlp-pot-provider/target/release/'
'bgutil-pot'
),
]
for path in file_paths:
if self._get_executable_path(path):
self.logger.debug(f'Found bgutil-pot at: {path}')
return path
# Fallback to PATH name if no file found
default_path = 'bgutil-pot'
self.logger.debug(
f'No CLI path found, defaulting to {default_path}')
return default_path
def is_available(self):
return self._check_cli(self._cli_path)
def _get_executable_path(self, cli_path):
"""Get the actual executable path, checking PATH or file existence."""
# For relative names (like 'bgutil-pot-generate'), search in PATH
if os.path.sep not in cli_path:
executable_path = shutil.which(cli_path)
if executable_path:
return executable_path
# For absolute/relative paths, check file existence directly
if os.path.isfile(cli_path):
return cli_path
return None
def _check_cli_impl(self, cli_path):
executable_path = self._get_executable_path(cli_path)
if not executable_path:
self.logger.debug(
f"Executable path doesn't exist: {cli_path}")
return False
stdout, stderr, returncode = Popen.run(
[executable_path, '--version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=self._GET_SERVER_VSN_TIMEOUT
)
if returncode:
self.logger.warning(
f'Failed to check executable version. '
f'Executable returned {returncode} exit status. '
f'stdout: {stdout}; stderr: {stderr}',
once=True)
return False
else:
self.logger.debug(f'bgutil-pot version: {stdout.strip()}')
return True
def _real_request_pot(
self,
request: PoTokenRequest,
) -> PoTokenResponse:
# used for CI check
self.logger.trace(
f'Generating POT via Rust executable: {self._cli_path}')
executable_path = self._get_executable_path(self._cli_path)
if not executable_path:
raise PoTokenProviderError(
f'Executable not found: {self._cli_path}')
command_args = [executable_path]
if proxy := request.request_proxy:
command_args.extend(['-p', proxy])
command_args.extend(['-c', get_webpo_content_binding(request)[0]])
if request.bypass_cache:
command_args.append('--bypass-cache')
if request.request_source_address:
command_args.extend(
['--source-address', request.request_source_address])
if request.request_verify_tls is False:
command_args.append('--disable-tls-verification')
self.logger.info(
f'Generating a {request.context.value} PO Token for '
f'{request.internal_client_name} client via bgutil '
f'Rust executable',
)
self.logger.debug(
f'Executing command to get POT via Rust executable: '
f'{" ".join(command_args)}'
)
try:
stdout, stderr, returncode = Popen.run(
command_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=self._GETPOT_TIMEOUT
)
except subprocess.TimeoutExpired as e:
raise PoTokenProviderError(
f'_get_pot_via_cli failed: Timeout expired when trying '
f'to run executable (caused by {e!r})'
)
except Exception as e:
raise PoTokenProviderError(
f'_get_pot_via_cli failed: Unable to run executable '
f'(caused by {e!r})'
) from e
msg = ''
if stdout_extra := stdout.strip().splitlines()[:-1]:
msg = f'stdout:\n{stdout_extra}\n'
if stderr_stripped := stderr.strip(): # Empty strings are falsy
msg += f'stderr:\n{stderr_stripped}\n'
msg = msg.strip()
if msg:
self.logger.trace(msg)
if returncode:
raise PoTokenProviderError(
f'_get_pot_via_cli failed with returncode {returncode}')
try:
json_resp = stdout.splitlines()[-1]
self.logger.trace(f'JSON response:\n{json_resp}')
# The JSON response is always the last line
cli_data_resp = json.loads(json_resp)
except json.JSONDecodeError as e:
raise PoTokenProviderError(
f'Error parsing JSON response from _get_pot_via_cli '
f'(caused by {e!r})'
) from e
if 'poToken' not in cli_data_resp:
raise PoTokenProviderError(
'The executable did not respond with a po_token')
return PoTokenResponse(po_token=cli_data_resp['poToken'])
@register_preference(BgUtilCliPTP)
def bgutil_cli_getpot_preference(provider, request):
return 1
__all__ = [BgUtilCliPTP.__name__,
bgutil_cli_getpot_preference.__name__]

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import functools
import json
import time
from yt_dlp.extractor.youtube.pot.provider import (
PoTokenProviderError,
PoTokenProviderRejectedRequest,
PoTokenRequest,
PoTokenResponse,
register_preference,
register_provider,
)
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
from yt_dlp.networking.common import Request
from yt_dlp.networking.exceptions import HTTPError, TransportError
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
@register_provider
class BgUtilHTTPPTP(BgUtilPTPBase):
PROVIDER_NAME = 'bgutil:http'
DEFAULT_BASE_URL = 'http://127.0.0.1:4416'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_server_check = 0
self._server_available = True
@functools.cached_property
def _base_url(self):
base_url = self._configuration_arg('base_url', default=[None])[0]
if base_url:
return base_url
# check deprecated arg
deprecated_base_url = self.ie._configuration_arg(
ie_key='youtube', key='getpot_bgutil_baseurl', default=[None])[0]
if deprecated_base_url:
self._warn_and_raise(
"'youtube:getpot_bgutil_baseurl' extractor arg is deprecated, "
"use 'youtubepot-bgutilhttp:base_url' instead"
)
# default if no arg was passed
self.logger.debug(
f'No base_url provided, defaulting to {self.DEFAULT_BASE_URL}')
return self.DEFAULT_BASE_URL
def _check_server_availability(self, ctx: PoTokenRequest):
if self._last_server_check + 60 > time.time():
return self._server_available
self._server_available = False
try:
self.logger.trace(
f'Checking server availability at {self._base_url}/ping')
response = json.load(self._request_webpage(Request(
f'{self._base_url}/ping',
extensions={'timeout': self._GET_SERVER_VSN_TIMEOUT},
proxies={'all': None}
),
note=False))
except TransportError as e:
# the server may be down
script_path_provided = self.ie._configuration_arg(
ie_key='youtubepot-bgutilscript',
key='script_path',
default=[None]
)[0] is not None
warning_base = (
f'Error reaching GET {self._base_url}/ping '
f'(caused by {e.__class__.__name__}). '
)
if script_path_provided: # server down is expected, log info
self._info_and_raise(
warning_base +
'This is expected if you are using the script method.'
)
else:
self._warn_and_raise(
warning_base +
f'Please make sure that the server is reachable at '
f'{self._base_url}.'
)
return
except HTTPError as e:
# may be an old server, don't raise
self.logger.warning(
f'HTTP Error reaching GET /ping (caused by {e!r})', once=True)
return
except json.JSONDecodeError as e:
# invalid server
self._warn_and_raise(
f'Error parsing ping response JSON (caused by {e!r})')
return
except Exception as e:
self._warn_and_raise(
f'Unknown error reaching GET /ping (caused by {e!r})',
raise_from=e
)
return
else:
version = response.get("version", "unknown")
self.logger.debug(f'HTTP server version: {version}')
self._server_available = True
return True
finally:
self._last_server_check = time.time()
def is_available(self):
return (self._server_available or
self._last_server_check + 60 < int(time.time()))
def _real_request_pot(
self,
request: PoTokenRequest,
) -> PoTokenResponse:
if not self._check_server_availability(request):
raise PoTokenProviderRejectedRequest(
f'{self.PROVIDER_NAME} server is not available')
# used for CI check
self.logger.trace('Generating POT via HTTP server')
disable_innertube = bool(
self._configuration_arg('disable_innertube', default=[None])[0]
)
challenge = self._get_attestation(
None if disable_innertube else request.video_webpage
)
# The challenge is falsy when the webpage and the challenge are
# unavailable. In this case, we need to disable /att/get since
# it's broken for web_music
if not challenge and request.internal_client_name == 'web_music':
if not disable_innertube: # if not already set, warn the user
self.logger.warning(
'BotGuard challenges could not be obtained from the '
'webpage, overriding disable_innertube=True because '
'InnerTube challenges are currently broken for the '
'web_music client. Pass disable_innertube=1 to suppress '
'this warning.'
)
disable_innertube = True
try:
response = self._request_webpage(
request=Request(
f'{self._base_url}/get_pot', data=json.dumps({
'bypass_cache': request.bypass_cache,
'challenge': challenge,
'content_binding': get_webpo_content_binding(
request
)[0],
'disable_innertube': disable_innertube,
'disable_tls_verification': (
not request.request_verify_tls
),
'proxy': request.request_proxy,
'innertube_context': request.innertube_context,
'source_address': request.request_source_address,
}).encode(), headers={'Content-Type': 'application/json'},
extensions={'timeout': self._GETPOT_TIMEOUT},
proxies={'all': None}
),
note=f'Generating a {request.context.value} PO Token for '
f'{request.internal_client_name} client via bgutil '
f'HTTP server',
)
except Exception as e:
raise PoTokenProviderError(
f'Error reaching POST /get_pot (caused by {e!r})') from e
try:
response_json = json.load(response)
except Exception as e:
response_data = response.read().decode()
raise PoTokenProviderError(
f'Error parsing response JSON (caused by {e!r}). '
f'response = {response_data}'
) from e
if error_msg := response_json.get('error'):
raise PoTokenProviderError(error_msg)
if 'poToken' not in response_json:
raise PoTokenProviderError(
f'Server did not respond with a poToken. '
f'Received response: {response}'
)
po_token = response_json['poToken']
self.logger.trace(f'Generated POT: {po_token}')
return PoTokenResponse(po_token=po_token)
@register_preference(BgUtilHTTPPTP)
def bgutil_HTTP_getpot_preference(provider, request):
return 130
__all__ = [BgUtilHTTPPTP.__name__,
bgutil_HTTP_getpot_preference.__name__]

View File

@@ -21,6 +21,7 @@ use std::{
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
path::BaseDirectory,
Emitter, Manager, State, Emitter, Manager, State,
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
@@ -30,6 +31,7 @@ use tokio::{
time::sleep, time::sleep,
}; };
use tokio_tungstenite::accept_async; use tokio_tungstenite::accept_async;
use log::{info, error};
struct ImageCache(StdMutex<HashMap<String, String>>); struct ImageCache(StdMutex<HashMap<String, String>>);
@@ -184,6 +186,16 @@ fn get_current_app_path() -> Result<String, String> {
.into_owned()) .into_owned())
} }
#[tauri::command]
fn is_flatpak() -> bool {
std::env::var("FLATPAK").is_ok()
}
#[tauri::command]
fn get_appimage_path() -> Option<String> {
std::env::var("APPDIR").ok()
}
#[tauri::command] #[tauri::command]
async fn update_config( async fn update_config(
new_config: Config, new_config: Config,
@@ -346,21 +358,48 @@ async fn open_file_with_app(
) -> Result<(), String> { ) -> Result<(), String> {
if let Some(name) = &app_name { if let Some(name) = &app_name {
if name == "explorer" { if name == "explorer" {
println!("Revealing file: {} in explorer", file_path); info!("Revealing file: {} in explorer", file_path);
return app_handle return app_handle
.opener() .opener()
.reveal_item_in_dir(file_path) .reveal_item_in_dir(file_path)
.map_err(|e| e.to_string()); .map_err(|e| {
error!("Failed to reveal file in explorer: {}", e);
e.to_string()
});
} }
println!("Opening file: {} with app: {}", file_path, name); info!("Opening file: {} with app: {}", file_path, name);
} else { } else {
println!("Opening file: {} with default app", file_path); info!("Opening file: {} with default app", file_path);
} }
app_handle app_handle
.opener() .opener()
.open_path(file_path, app_name) .open_path(file_path, app_name)
.map_err(|e| e.to_string()) .map_err(|e| {
error!("Failed to open file: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn open_link_with_app(
app_handle: tauri::AppHandle,
url: String,
app_name: Option<String>,
) -> Result<(), String> {
if let Some(name) = &app_name {
info!("Opening link: {} with app: {}", url, name);
} else {
info!("Opening link: {} with default app", url);
}
app_handle
.opener()
.open_url(url, app_name)
.map_err(|e| {
error!("Failed to open link: {}", e);
e.to_string()
})
} }
#[tauri::command] #[tauri::command]
@@ -574,6 +613,20 @@ pub async fn run() {
.build(app) .build(app)
.map_err(|e| format!("Failed to create tray: {}", e))?; .map_err(|e| format!("Failed to create tray: {}", e))?;
// Fix tray icon in sandboxed environments (e.g., Flatpak)
// libappindicator uses the full path of the icon in dbus messages,
// so the path needs to be accessible from both the host and the sandbox.
// The default /tmp path doesn't work across sandbox boundaries.
if let Ok(local_data_path) = app
.path()
.resolve("tray-icon", BaseDirectory::AppLocalData)
{
let _ = fs::create_dir_all(&local_data_path);
let _ = tray.set_temp_dir_path(Some(local_data_path));
// Re-set the icon so it gets written to the new temp dir path
let _ = tray.set_icon(Some(app.default_window_icon().unwrap().clone()));
}
app.manage(tray); app.manage(tray);
let window = app.get_webview_window("main").unwrap(); let window = app.get_webview_window("main").unwrap();
@@ -594,6 +647,7 @@ pub async fn run() {
kill_all_process, kill_all_process,
fetch_image, fetch_image,
open_file_with_app, open_file_with_app,
open_link_with_app,
list_ongoing_downloads, list_ongoing_downloads,
pause_ongoing_downloads, pause_ongoing_downloads,
send_to_extension, send_to_extension,
@@ -604,6 +658,8 @@ pub async fn run() {
get_config_file_path, get_config_file_path,
restart_websocket_server, restart_websocket_server,
get_current_app_path, get_current_app_path,
is_flatpak,
get_appimage_path
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "NeoDLP", "productName": "NeoDLP",
"mainBinaryName": "neodlp", "mainBinaryName": "neodlp",
"version": "0.4.0", "version": "0.4.2",
"identifier": "com.neosubhamoy.neodlp", "identifier": "com.neosubhamoy.neodlp",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -29,6 +29,7 @@
"targets": ["deb", "rpm"], "targets": ["deb", "rpm"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"licenseFile": "../LICENSE", "licenseFile": "../LICENSE",
"category": "Utility",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -39,8 +40,12 @@
"externalBin": [ "externalBin": [
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
},
"linux": { "linux": {
"deb": { "deb": {
"depends": ["ffmpeg"], "depends": ["ffmpeg"],

View File

@@ -0,0 +1,51 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "NeoDLP",
"width": 1080,
"height": 680,
"decorations": false,
"visible": false
}
],
"security": {
"csp": null,
"capabilities": [
"default",
"shell-scope"
]
}
},
"bundle": {
"active": true,
"targets": ["deb"],
"createUpdaterArtifacts": true,
"licenseFile": "../LICENSE",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/aria2c",
"binaries/deno",
"binaries/neodlp-pot"
],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}
}
}

View File

@@ -29,6 +29,7 @@
"targets": ["deb", "rpm", "appimage"], "targets": ["deb", "rpm", "appimage"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"licenseFile": "../LICENSE", "licenseFile": "../LICENSE",
"category": "Utility",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -39,8 +40,12 @@
"externalBin": [ "externalBin": [
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
},
"linux": { "linux": {
"deb": { "deb": {
"depends": ["ffmpeg"], "depends": ["ffmpeg"],

View File

@@ -39,13 +39,15 @@
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost", "target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json", "resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json", "resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist" "resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"macOS": { "macOS": {
"providerShortName": "neosubhamoy" "providerShortName": "neosubhamoy"

View File

@@ -39,13 +39,15 @@
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost", "target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json", "resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json", "resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist" "resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"macOS": { "macOS": {
"providerShortName": "neosubhamoy" "providerShortName": "neosubhamoy"

View File

@@ -41,12 +41,14 @@
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe", "target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
"resources/msghost-manifest/windows/chrome.json": "chrome.json", "resources/msghost-manifest/windows/chrome.json": "chrome.json",
"resources/msghost-manifest/windows/firefox.json": "firefox.json" "resources/msghost-manifest/windows/firefox.json": "firefox.json",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"windows": { "windows": {
"wix": { "wix": {

View File

@@ -4,14 +4,14 @@ import { AppContext } from "@/providers/appContextProvider";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { arch, exeExtension } from "@tauri-apps/plugin-os"; import { arch, exeExtension } from "@tauri-apps/plugin-os";
import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path"; import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path";
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useEnvironmentStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { isObjEmpty} from "@/utils"; import { isObjEmpty} from "@/utils";
import { Command } from "@tauri-apps/plugin-shell"; import { Command } from "@tauri-apps/plugin-shell";
import { useUpdateDownloadStatus } from "@/services/mutations"; import { useUpdateDownloadStatus } from "@/services/mutations";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useFetchAllDownloadStates, useFetchAllkVPairs, useFetchAllSettings } from "@/services/queries"; import { useFetchAllDownloadStates, useFetchAllkVPairs, useFetchAllSettings } from "@/services/queries";
import { config } from "@/config"; import { config } from "@/config";
import * as fs from "@tauri-apps/plugin-fs"; // import * as fs from "@tauri-apps/plugin-fs";
import { useYtDlpUpdater } from "@/helpers/use-ytdlp-updater"; import { useYtDlpUpdater } from "@/helpers/use-ytdlp-updater";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
@@ -25,6 +25,10 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLogger } from "@/helpers/use-logger"; import { useLogger } from "@/helpers/use-logger";
import useDownloader from "@/helpers/use-downloader"; import useDownloader from "@/helpers/use-downloader";
import usePotServer from "@/helpers/use-pot-server";
import { useLinuxRegisterer } from "@/helpers/use-linux-registerer";
import { invoke } from "@tauri-apps/api/core";
export default function App({ children }: { children: React.ReactNode }) { export default function App({ children }: { children: React.ReactNode }) {
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
@@ -36,9 +40,14 @@ export default function App({ children }: { children: React.ReactNode }) {
const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates); const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates);
const setPath = useBasePathsStore((state) => state.setPath); const setPath = useBasePathsStore((state) => state.setPath);
const setIsFlatpak = useEnvironmentStore((state) => state.setIsFlatpak);
const setIsAppimage = useEnvironmentStore((state) => state.setIsAppimage);
const setAppDirPath = useEnvironmentStore((state) => state.setAppDirPath);
const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings); const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings);
const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey); const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey);
const appVersion = useSettingsPageStatesStore(state => state.appVersion); const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const isRunningPotServer = useSettingsPageStatesStore(state => state.isRunningPotServer);
const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion); const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion);
const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion); const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion);
const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion); const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion);
@@ -50,6 +59,7 @@ export default function App({ children }: { children: React.ReactNode }) {
download_dir: DOWNLOAD_DIR, download_dir: DOWNLOAD_DIR,
theme: APP_THEME, theme: APP_THEME,
color_scheme: APP_COLOR_SCHEME, color_scheme: APP_COLOR_SCHEME,
use_potoken: USE_POTOKEN,
} = useSettingsPageStatesStore(state => state.settings); } = useSettingsPageStatesStore(state => state.settings);
const erroredDownloadIds = useDownloaderPageStatesStore((state) => state.erroredDownloadIds); const erroredDownloadIds = useDownloaderPageStatesStore((state) => state.erroredDownloadIds);
@@ -63,10 +73,13 @@ export default function App({ children }: { children: React.ReactNode }) {
const currentPlatform = platform(); const currentPlatform = platform();
const { updateYtDlp } = useYtDlpUpdater(); const { updateYtDlp } = useYtDlpUpdater();
const { registerToMac } = useMacOsRegisterer(); const { registerToMac } = useMacOsRegisterer();
const { registerToLinux } = useLinuxRegisterer();
const { checkForAppUpdate } = useAppUpdater(); const { checkForAppUpdate } = useAppUpdater();
const { startPotServer, stopPotServer } = usePotServer();
const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey); const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey);
const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check); const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check);
const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version); const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version);
const linuxRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.linux_registered_version);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const downloadStatusUpdater = useUpdateDownloadStatus(); const downloadStatusUpdater = useUpdateDownloadStatus();
@@ -76,7 +89,9 @@ export default function App({ children }: { children: React.ReactNode }) {
const hasRunYtDlpAutoUpdateRef = useRef(false); const hasRunYtDlpAutoUpdateRef = useRef(false);
const hasRunAppUpdateCheckRef = useRef(false); const hasRunAppUpdateCheckRef = useRef(false);
const hasRunPotServerStatusCheckRef = useRef(false);
const isRegisteredToMacOsRef = useRef(false); const isRegisteredToMacOsRef = useRef(false);
const isRegisteredToLinuxRef = useRef(false);
const pendingErrorUpdatesRef = useRef<Set<string>>(new Set()); const pendingErrorUpdatesRef = useRef<Set<string>>(new Set());
const { fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload, processQueuedDownloads } = useDownloader(); const { fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload, processQueuedDownloads } = useDownloader();
@@ -98,6 +113,39 @@ export default function App({ children }: { children: React.ReactNode }) {
appWindow.onCloseRequested(handleCloseRequested); appWindow.onCloseRequested(handleCloseRequested);
}, []); }, []);
// Cleanup before page refresh/unload
useEffect(() => {
const handleBeforeUnload = (_event: BeforeUnloadEvent) => {
if (isRunningPotServer) {
stopPotServer();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [stopPotServer]);
// Detect sandbox environments
useEffect(() => {
const detectEnvironment = async () => {
try {
const isFlatpak = await invoke<boolean>('is_flatpak');
const appimagePath = await invoke<string | null>('get_appimage_path');
console.log('Environment detection results:', { isFlatpak, appimagePath });
if (isFlatpak) setIsFlatpak(true);
if (appimagePath) {
setIsAppimage(true);
setAppDirPath(appimagePath);
}
} catch (e) {
console.error('Failed to detect environment:', e);
}
}
detectEnvironment();
}, [setIsFlatpak, setIsAppimage, setAppDirPath]);
// Listen for websocket messages // Listen for websocket messages
useEffect(() => { useEffect(() => {
const unlisten = listen<WebSocketMessage>('websocket-message', (event) => { const unlisten = listen<WebSocketMessage>('websocket-message', (event) => {
@@ -160,22 +208,23 @@ export default function App({ children }: { children: React.ReactNode }) {
try { try {
const currentArch = arch(); const currentArch = arch();
const currentExeExtension = exeExtension(); const currentExeExtension = exeExtension();
const isFlatpak = await invoke<boolean>('is_flatpak');
const downloadDirPath = await downloadDir(); const downloadDirPath = await downloadDir();
const tempDirPath = await tempDir(); const tempDirPath = await tempDir();
const resourceDirPath = await resourceDir(); const resourceDirPath = await resourceDir();
const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`); const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`);
const tempDownloadDirPath = await join(tempDirPath, config.appPkgName, 'downloads'); const tempDownloadDirPath = isFlatpak ? await join(downloadDirPath, config.appName, '.tempdownloads') : await join(tempDirPath, config.appPkgName, 'downloads');
const appDownloadDirPath = await join(downloadDirPath, config.appName); const appDownloadDirPath = await join(downloadDirPath, config.appName);
if (!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) }); // if (!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) });
setPath('ffmpegPath', ffmpegPath); setPath('ffmpegPath', ffmpegPath);
setPath('tempDownloadDirPath', tempDownloadDirPath); setPath('tempDownloadDirPath', tempDownloadDirPath);
if (DOWNLOAD_DIR) { if (DOWNLOAD_DIR) {
setPath('downloadDirPath', DOWNLOAD_DIR); setPath('downloadDirPath', DOWNLOAD_DIR);
} else { } else {
if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) }); // if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) });
setPath('downloadDirPath', appDownloadDirPath); setPath('downloadDirPath', appDownloadDirPath);
} }
console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath }); console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath });
@@ -246,6 +295,7 @@ export default function App({ children }: { children: React.ReactNode }) {
// Check for yt-dlp auto-update // Check for yt-dlp auto-update
useEffect(() => { useEffect(() => {
const handleYtDlpAutoUpdate = async () => {
// Only run once when both settings and KV pairs are loaded // Only run once when both settings and KV pairs are loaded
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
console.log("Skipping yt-dlp auto-update check, waiting for configs to load..."); console.log("Skipping yt-dlp auto-update check, waiting for configs to load...");
@@ -256,6 +306,11 @@ export default function App({ children }: { children: React.ReactNode }) {
console.log("Auto-update check already performed in this session, skipping"); console.log("Auto-update check already performed in this session, skipping");
return; return;
} }
const isFlatpak = await invoke<boolean>('is_flatpak');
if (isFlatpak) {
console.log("Flatpak detected! Skipping yt-dlp auto-update");
return;
}
hasRunYtDlpAutoUpdateRef.current = true; hasRunYtDlpAutoUpdateRef.current = true;
console.log("Checking yt-dlp auto-update with loaded config values:", { console.log("Checking yt-dlp auto-update with loaded config values:", {
autoUpdate: YTDLP_AUTO_UPDATE, autoUpdate: YTDLP_AUTO_UPDATE,
@@ -270,6 +325,34 @@ export default function App({ children }: { children: React.ReactNode }) {
} else { } else {
console.log("Skipping yt-dlp auto-update, either disabled or recently updated."); console.log("Skipping yt-dlp auto-update, either disabled or recently updated.");
} }
}
handleYtDlpAutoUpdate()
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
// Check POT server status and auto-start if enabled
useEffect(() => {
// Only run once when both settings and KV pairs are loaded
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
console.log("Skipping POT server status check, waiting for configs to load...");
return;
}
// Skip if we've already run the POT server status check once
if (hasRunPotServerStatusCheckRef.current) {
console.log("POT server status check already performed in this session, skipping");
return;
}
hasRunPotServerStatusCheckRef.current = true;
console.log("Checking POT server status with loaded config values:", {
usePotoken: USE_POTOKEN,
});
if (USE_POTOKEN) {
console.log("Auto-starting POT server...");
startPotServer().catch((error) => {
console.error("Error starting POT server:", error);
});
} else {
console.log("Skipping POT server auto-start, not enabled.");
}
}, [isSettingsStatePropagated, isKvPairsStatePropagated]); }, [isSettingsStatePropagated, isKvPairsStatePropagated]);
// Check for MacOS auto-registration // Check for MacOS auto-registration
@@ -307,6 +390,41 @@ export default function App({ children }: { children: React.ReactNode }) {
} }
}, [isSettingsStatePropagated, isKvPairsStatePropagated]); }, [isSettingsStatePropagated, isKvPairsStatePropagated]);
// Check for Linux auto-registration
useEffect(() => {
// Only run once when both settings and KV pairs are loaded
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
console.log("Skipping Linux auto registration, waiting for configs to load...");
return;
}
// Skip if we've already run the linux auto-registration once
if (isRegisteredToLinuxRef.current) {
console.log("Linux auto registration check already performed in this session, skipping");
return;
}
isRegisteredToLinuxRef.current = true;
console.log("Checking Linux auto registration with loaded config values:", {
appVersion: appVersion,
registeredVersion: linuxRegisteredVersion
});
if (currentPlatform === 'linux' && (!linuxRegisteredVersion || linuxRegisteredVersion !== appVersion)) {
console.log("Running Linux auto registration...");
LOG.info('NEODLP', 'Running Linux registration');
registerToLinux().then((result: { success: boolean, message: string }) => {
if (result.success) {
console.log("Linux registration successful:", result.message);
LOG.info('NEODLP', 'Linux registration successful');
} else {
console.error("Linux registration failed:", result.message);
LOG.error('NEODLP', `Linux registration failed: ${result.message}`);
}
}).catch((error) => {
console.error("Error during Linux registration:", error);
LOG.error('NEODLP', `Error during Linux registration: ${error}`);
});
}
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
useEffect(() => { useEffect(() => {
if (isSuccessFetchingDownloadStates && downloadStates) { if (isSuccessFetchingDownloadStates && downloadStates) {
// console.log("Download States fetched successfully:", downloadStates); // console.log("Download States fetched successfully:", downloadStates);
@@ -341,7 +459,7 @@ export default function App({ children }: { children: React.ReactNode }) {
}); });
}); });
const timeoutIds: NodeJS.Timeout[] = []; const timeoutIds: ReturnType<typeof setTimeout>[] = [];
unexpectedErrors.forEach((downloadId) => { unexpectedErrors.forEach((downloadId) => {
pendingErrorUpdatesRef.current.add(downloadId); pendingErrorUpdatesRef.current.add(downloadId);

View File

@@ -0,0 +1,110 @@
import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "type" | "onChange" | "value"> {
value?: number
defaultValue?: number
min?: number
max?: number
step?: number
onChange?: (value: number) => void
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
className,
value: controlledValue,
defaultValue = 0,
min = -Infinity,
max = Infinity,
step = 1,
onChange,
disabled,
readOnly,
...props
},
ref
) => {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const isControlled = controlledValue !== undefined
const currentValue = isControlled ? controlledValue : internalValue
const updateValue = (newValue: number) => {
const clamped = Math.min(max, Math.max(min, newValue))
if (!isControlled) {
setInternalValue(clamped)
}
onChange?.(clamped)
}
const handleIncrement = () => {
if (!disabled && !readOnly) {
updateValue(currentValue + step)
}
}
const handleDecrement = () => {
if (!disabled && !readOnly) {
updateValue(currentValue - step)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseFloat(e.target.value)
if (!isNaN(parsed)) {
updateValue(parsed)
} else if (e.target.value === "" || e.target.value === "-") {
if (!isControlled) {
setInternalValue(0)
}
}
}
return (
<div className={cn("relative flex items-center", className)}>
<Input
type="number"
ref={ref}
value={currentValue}
onChange={handleInputChange}
min={min}
max={max}
step={step}
disabled={disabled}
readOnly={readOnly}
className="pr-16 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield] focus-visible:ring-0"
{...props}
/>
<div className="absolute right-0 flex h-full items-center">
<button
type="button"
onClick={handleDecrement}
disabled={disabled || readOnly || currentValue <= min}
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors border-x"
aria-label="Decrement"
tabIndex={-1}
>
<Minus className="size-3.5" />
</button>
<button
type="button"
onClick={handleIncrement}
disabled={disabled || readOnly || currentValue >= max}
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors rounded-r-md"
aria-label="Increment"
tabIndex={-1}
>
<Plus className="size-3.5" />
</button>
</div>
</div>
)
}
)
NumberInput.displayName = "NumberInput"
export { NumberInput }

View File

@@ -47,7 +47,7 @@ export default function Navbar() {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent side="bottom">
<p>Logs</p> <p>Logs</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -4,7 +4,7 @@ import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useLibraryPageStatesStore } from "@/services/store"; import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useEnvironmentStore, useLibraryPageStatesStore } from "@/services/store";
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, paginate } from "@/utils"; import { formatBitrate, formatCodec, formatDurationString, formatFileSize, paginate } from "@/utils";
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react"; import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@@ -33,6 +33,8 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions); const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked); const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked);
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const downloadStateDeleter = useDeleteDownloadState(); const downloadStateDeleter = useDeleteDownloadState();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -276,10 +278,12 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
<Play className="w-4 h-4" /> <Play className="w-4 h-4" />
Open Open
</Button> </Button>
{!isFlatpak && (
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}> <Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
<FolderInput className="w-4 h-4" /> <FolderInput className="w-4 h-4" />
Reveal Reveal
</Button> </Button>
)}
<Button size="sm" variant="outline" onClick={() => handleSearch(state.url, state.playlist_id ? true : false)}> <Button size="sm" variant="outline" onClick={() => handleSearch(state.url, state.playlist_id ? true : false)}>
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
Search Search
@@ -352,7 +356,7 @@ export function CompletedDownloads({ downloads }: CompletedDownloadsProps) {
<Empty className="mt-10"> <Empty className="mt-10">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<CircleArrowDown /> <CircleArrowDown className="stroke-primary" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No Completed Downloads</EmptyTitle> <EmptyTitle>No Completed Downloads</EmptyTitle>
<EmptyDescription> <EmptyDescription>

View File

@@ -280,7 +280,7 @@ export function IncompleteDownloads({ downloads }: IncompleteDownloadsProps) {
<Empty className="mt-10"> <Empty className="mt-10">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<CircleCheck /> <CircleCheck className="stroke-primary" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No Incomplete Downloads</EmptyTitle> <EmptyTitle>No Incomplete Downloads</EmptyTitle>
<EmptyDescription> <EmptyDescription>

View File

@@ -1,13 +1,13 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBasePathsStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { useBasePathsStore, useDownloaderPageStatesStore, useDownloadStatesStore, useEnvironmentStore, useSettingsPageStatesStore } from "@/services/store";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { BadgeCheck, BellRing, BrushCleaning, Bug, Cookie, ExternalLink, FilePen, FileVideo, Folder, FolderOpen, Github, Globe, Heart, Info, Loader2, LucideIcon, Mail, Monitor, Moon, Package, Scale, ShieldMinus, SquareTerminal, Sun, Terminal, Trash, TriangleAlert, WandSparkles, Wifi, Wrench } from "lucide-react"; import { BadgeCheck, BellRing, BrushCleaning, Bug, CircleCheck, Cookie, ExternalLink, FilePen, FileVideo, Folder, FolderOpen, Github, Globe, Heart, Info, KeyRound, Loader2, LucideIcon, Mail, Monitor, Moon, Package, Scale, ShieldMinus, SquareTerminal, Sun, Terminal, Timer, Trash, TriangleAlert, WandSparkles, Wifi, Wrench } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -34,6 +34,8 @@ import { config } from "@/config";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import neosubhamoyImage from "@/assets/images/neosubhamoy.jpg"; import neosubhamoyImage from "@/assets/images/neosubhamoy.jpg";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { NumberInput } from "@/components/custom/numberInput";
import usePotServer from "@/helpers/use-pot-server";
const proxyUrlSchema = z.object({ const proxyUrlSchema = z.object({
url: z.url({ url: z.url({
@@ -66,6 +68,59 @@ const filenameTemplateShcema = z.object({
template: z.string().min(1, { message: "Filename Template is required" }), template: z.string().min(1, { message: "Filename Template is required" }),
}); });
const minMaxSleepIntervalSchema = z.object({
min_sleep_interval: z.coerce.number<number>({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "Minimum Sleep Interval is required"
: "Minimum Sleep Interval must be a valid number"
}).int({
message: "Minimum Sleep Interval must be an integer"
}).min(1, {
message: "Minimum Sleep Interval must be at least 1 second"
}).max(3600, {
message: "Minimum Sleep Interval must be at most 3600 seconds (1 hour)"
}),
max_sleep_interval: z.coerce.number<number>({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "Maximum Sleep Interval is required"
: "Maximum Sleep Interval must be a valid number"
}).int({
message: "Maximum Sleep Interval must be an integer"
}).min(1, {
message: "Maximum Sleep Interval must be at least 1 second"
}).max(3600, {
message: "Maximum Sleep Interval must be at most 3600 seconds (1 hour)"
}),
})
const requestSleepIntervalSchema = z.object({
request_sleep_interval: z.coerce.number<number>({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "Request Sleep Interval is required"
: "Request Sleep Interval must be a valid number"
}).int({
message: "Request Sleep Interval must be an integer"
}).min(1, {
message: "Request Sleep Interval must be at least 1 second"
}).max(3600, {
message: "Request Sleep Interval must be at most 3600 seconds (1 hour)"
}),
})
const potServerPortSchema = z.object({
port: z.coerce.number<number>({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "POT Server Port is required"
: "POT Server Port must be a valid number"
}).int({
message: "POT Server Port must be an integer"
}).min(4000, {
message: "POT Server Port must be at least 4000"
}).max(5000, {
message: "POT Server Port must be at most 5000"
}),
});
function AppGeneralSettings() { function AppGeneralSettings() {
const { saveSettingsKey } = useSettings(); const { saveSettingsKey } = useSettings();
@@ -230,6 +285,8 @@ function AppAppearanceSettings() {
function AppFolderSettings() { function AppFolderSettings() {
const { saveSettingsKey } = useSettings(); const { saveSettingsKey } = useSettings();
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger); const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset); const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
@@ -309,6 +366,7 @@ function AppFolderSettings() {
<Input className="focus-visible:ring-0" type="text" placeholder="Select download directory" value={downloadDirPath ?? 'Unknown'} readOnly/> <Input className="focus-visible:ring-0" type="text" placeholder="Select download directory" value={downloadDirPath ?? 'Unknown'} readOnly/>
<Button <Button
variant="outline" variant="outline"
disabled={isFlatpak}
onClick={async () => { onClick={async () => {
try { try {
const folder = await open({ const folder = await open({
@@ -340,7 +398,7 @@ function AppFolderSettings() {
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
disabled={ongoingDownloads.length > 0} disabled={ongoingDownloads.length > 0 || isFlatpak}
> >
<BrushCleaning className="size-4" /> Clean <BrushCleaning className="size-4" /> Clean
</Button> </Button>
@@ -668,9 +726,10 @@ function AppNetworkSettings() {
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full"> <FormItem className="w-full">
<FormControl> <FormControl>
<Input <NumberInput
className="focus-visible:ring-0" className="w-full"
placeholder="Enter rate limit in bytes/s" placeholder="Enter rate limit in bytes/s"
min={0}
readOnly={useCustomCommands} readOnly={useCustomCommands}
{...field} {...field}
/> />
@@ -853,7 +912,7 @@ function AppSponsorblockSettings() {
return ( return (
<> <>
<div className="sponsorblock"> <div className="sponsorblock">
<h3 className="font-semibold">Sponsor Block</h3> <h3 className="font-semibold">Sponsorblock</h3>
<p className="text-xs text-muted-foreground mb-3">Use sponsorblock to remove/mark unwanted segments in videos (sponsorships, intros, outros, etc.)</p> <p className="text-xs text-muted-foreground mb-3">Use sponsorblock to remove/mark unwanted segments in videos (sponsorships, intros, outros, etc.)</p>
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
<Switch <Switch
@@ -978,6 +1037,369 @@ function AppSponsorblockSettings() {
); );
} }
function AppDelaySettings() {
const { saveSettingsKey } = useSettings();
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
const useDelay = useSettingsPageStatesStore(state => state.settings.use_delay);
const useSearchDelay = useSettingsPageStatesStore(state => state.settings.use_search_delay);
const delayMode = useSettingsPageStatesStore(state => state.settings.delay_mode);
const minSleepInterval = useSettingsPageStatesStore(state => state.settings.min_sleep_interval);
const maxSleepInterval = useSettingsPageStatesStore(state => state.settings.max_sleep_interval);
const requestSleepInterval = useSettingsPageStatesStore(state => state.settings.request_sleep_interval);
const delayPlaylistOnly = useSettingsPageStatesStore(state => state.settings.delay_playlist_only);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const minMaxSleepIntervalForm = useForm<z.infer<typeof minMaxSleepIntervalSchema>>({
resolver: zodResolver(minMaxSleepIntervalSchema),
defaultValues: {
min_sleep_interval: minSleepInterval,
max_sleep_interval: maxSleepInterval,
},
mode: "onChange",
});
const watchedMinSleepInterval = minMaxSleepIntervalForm.watch("min_sleep_interval");
const watchedMaxSleepInterval = minMaxSleepIntervalForm.watch("max_sleep_interval");
const { errors: minMaxSleepIntervalFormErrors } = minMaxSleepIntervalForm.formState;
function handleMinMaxSleepIntervalSubmit(values: z.infer<typeof minMaxSleepIntervalSchema>) {
try {
saveSettingsKey('min_sleep_interval', values.min_sleep_interval);
saveSettingsKey('max_sleep_interval', values.max_sleep_interval);
toast.success("Sleep Intervals updated", {
description: `Minimum Sleep Interval changed to ${values.min_sleep_interval} seconds, Maximum Sleep Interval changed to ${values.max_sleep_interval} seconds`,
});
} catch (error) {
console.error("Error changing sleep intervals:", error);
toast.error("Failed to change sleep intervals", {
description: "An error occurred while trying to change the sleep intervals. Please try again.",
});
}
}
const requestSleepIntervalForm = useForm<z.infer<typeof requestSleepIntervalSchema>>({
resolver: zodResolver(requestSleepIntervalSchema),
defaultValues: {
request_sleep_interval: requestSleepInterval,
},
mode: "onChange",
});
const watchedRequestSleepInterval = requestSleepIntervalForm.watch("request_sleep_interval");
const { errors: requestSleepIntervalFormErrors } = requestSleepIntervalForm.formState;
function handleRequestSleepIntervalSubmit(values: z.infer<typeof requestSleepIntervalSchema>) {
try {
saveSettingsKey('request_sleep_interval', values.request_sleep_interval);
toast.success("Request Sleep Interval updated", {
description: `Request Sleep Interval changed to ${values.request_sleep_interval} seconds`,
});
} catch (error) {
console.error("Error changing request sleep interval:", error);
toast.error("Failed to change request sleep interval", {
description: "An error occurred while trying to change the request sleep interval. Please try again.",
});
}
}
useEffect(() => {
if (formResetTrigger > 0) {
minMaxSleepIntervalForm.reset();
requestSleepIntervalForm.reset();
acknowledgeFormReset();
}
}, [formResetTrigger]);
return (
<>
<div className="delay">
<h3 className="font-semibold">Delay</h3>
<p className="text-xs text-muted-foreground mb-3">Use delay to prevent potential issues with some sites (bypass rate-limit, temporary ban, etc.)</p>
<div className="flex items-center space-x-2 mb-3">
<Switch
id="use-delay"
checked={useDelay}
onCheckedChange={(checked) => saveSettingsKey('use_delay', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-delay">Use Delay in Downloads</Label>
</div>
<div className="flex items-center space-x-2 mb-4">
<Switch
id="use-search-delay"
checked={useSearchDelay}
onCheckedChange={(checked) => saveSettingsKey('use_search_delay', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-search-delay">Use Delay in Search</Label>
</div>
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4"
value={delayMode}
onValueChange={(value) => saveSettingsKey('delay_mode', value)}
disabled={(!useDelay && !useSearchDelay) || useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="delay-auto" />
<Label htmlFor="delay-auto">Auto (Default)</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="custom" id="delay-custom" />
<Label htmlFor="delay-custom">Custom</Label>
</div>
</RadioGroup>
<div className="flex flex-col gap-2 mt-5">
<Label className="text-xs mb-1">Minimum, Maximum Sleep Interval (in Seconds)</Label>
<Form {...minMaxSleepIntervalForm}>
<form onSubmit={minMaxSleepIntervalForm.handleSubmit(handleMinMaxSleepIntervalSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField
control={minMaxSleepIntervalForm.control}
name="min_sleep_interval"
disabled={delayMode !== "custom" || (!useDelay && !useSearchDelay)}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<NumberInput
className="w-full"
placeholder="Min sleep"
min={0}
readOnly={useCustomCommands}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={minMaxSleepIntervalForm.control}
name="max_sleep_interval"
disabled={delayMode !== "custom" || (!useDelay && !useSearchDelay)}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<NumberInput
className="w-full"
placeholder="Max sleep"
min={0}
readOnly={useCustomCommands}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={(!watchedMinSleepInterval || Number(watchedMinSleepInterval) === minSleepInterval) && (!watchedMaxSleepInterval || Number(watchedMaxSleepInterval) === maxSleepInterval) || Object.keys(minMaxSleepIntervalFormErrors).length > 0 || delayMode !== "custom" || (!useDelay && !useSearchDelay)}
>
Save
</Button>
</form>
</Form>
</div>
<div className="flex flex-col gap-2 mt-4 mb-2">
<Label className="text-xs mb-1">Request Sleep Interval (in Seconds)</Label>
<Form {...requestSleepIntervalForm}>
<form onSubmit={requestSleepIntervalForm.handleSubmit(handleRequestSleepIntervalSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField
control={requestSleepIntervalForm.control}
name="request_sleep_interval"
disabled={delayMode !== "custom" || (!useDelay && !useSearchDelay)}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<NumberInput
className="w-full"
placeholder="Request sleep"
min={0}
readOnly={useCustomCommands}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!watchedRequestSleepInterval || Number(watchedRequestSleepInterval) === requestSleepInterval || Object.keys(requestSleepIntervalFormErrors).length > 0 || delayMode !== "custom" || (!useDelay && !useSearchDelay)}
>
Save
</Button>
</form>
</Form>
</div>
<Label className="text-xs text-muted-foreground">(Configured: {minSleepInterval}s - {maxSleepInterval}s & {requestSleepInterval}s, Mode: {delayMode === 'auto' ? 'Auto' : 'Custom'}, Status: {useDelay && delayPlaylistOnly ? 'Playlist Only' : useDelay ? 'Downloads' : ''}{useDelay && useSearchDelay ? ', Search' : useSearchDelay ? 'Search' : !useDelay && !useSearchDelay ? 'Disabled' : ''}) (Default: 10s - 20s & 1s, Range: 1s - 3600s)</Label>
</div>
<div className="delay-playlist-only">
<h3 className="font-semibold">Delay Playlist Only</h3>
<p className="text-xs text-muted-foreground mb-3">Only apply delay for playlist/batch downloads, single video downloads will not be affected (recommended)</p>
<div className="flex items-center space-x-2 mb-4">
<Switch
id="delay-playlist-only"
checked={delayPlaylistOnly}
onCheckedChange={(checked) => saveSettingsKey('delay_playlist_only', checked)}
disabled={!useDelay || useCustomCommands}
/>
</div>
</div>
</>
);
}
function AppPoTokenSettings() {
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
const usePotoken = useSettingsPageStatesStore(state => state.settings.use_potoken);
const disableInnertube = useSettingsPageStatesStore(state => state.settings.disable_innertube);
const potServerPort = useSettingsPageStatesStore(state => state.settings.pot_server_port);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const isRunningPotServer = useSettingsPageStatesStore(state => state.isRunningPotServer);
const isStartingPotServer = useSettingsPageStatesStore(state => state.isStartingPotServer);
const isChangingPotServerPort = useSettingsPageStatesStore(state => state.isChangingPotServerPort);
const setIsChangingPotServerPort = useSettingsPageStatesStore(state => state.setIsChangingPotServerPort);
const { saveSettingsKey } = useSettings();
const { startPotServer, stopPotServer } = usePotServer();
const potServerPortForm = useForm<z.infer<typeof potServerPortSchema>>({
resolver: zodResolver(potServerPortSchema),
defaultValues: {
port: potServerPort,
},
mode: "onChange",
});
const watchedPotServerPort = potServerPortForm.watch("port");
const { errors: potServerPortFormErrors } = potServerPortForm.formState;
async function handlePotServerPortSubmit(values: z.infer<typeof potServerPortSchema>) {
setIsChangingPotServerPort(true);
try {
saveSettingsKey('pot_server_port', values.port);
if (isRunningPotServer) {
await stopPotServer();
await new Promise(resolve => setTimeout(resolve, 1000));
await startPotServer(values.port);
}
toast.success("POT Server Port updated", {
description: `PO Token Server Port changed to ${values.port}`,
});
} catch (error) {
console.error("Error changing PO Token Server Port:", error);
toast.error("Failed to change POT Server Port", {
description: "An error occurred while trying to change the PO Token Server Port. Please try again.",
});
} finally {
setIsChangingPotServerPort(false);
}
}
useEffect(() => {
if (formResetTrigger > 0) {
potServerPortForm.reset();
acknowledgeFormReset();
}
}, [formResetTrigger]);
return (
<>
<div className="potoken">
<h3 className="font-semibold">PO Token</h3>
<p className="text-xs text-muted-foreground mb-3">Generate proof-of-origin token for youtube to make seem your traffic more legitimate (bypasses some bot-protection checks, sometimes requires cookies)</p>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="use-potoken"
checked={usePotoken}
onCheckedChange={async (checked) => {
saveSettingsKey('use_potoken', checked);
if (checked) {
await startPotServer();
} else {
await stopPotServer();
}
}}
disabled={useCustomCommands || isStartingPotServer || isChangingPotServerPort}
/>
<Label htmlFor="use-potoken">Use PO Token</Label>
</div>
<Label className="text-xs text-muted-foreground flex items-center">
<span className="mr-1">NeoDLP POT Server is</span>
{isStartingPotServer ? (
<span className="text-amber-600 dark:text-amber-500 underline">Starting</span>
) : isRunningPotServer ? (
<span className="text-emerald-600 dark:text-emerald-500 underline">Running</span>
) : (
<span className="text-red-600 dark:text-red-500 underline">Not Running</span>
)}
{isRunningPotServer && potServerPort ? (
<span className="ml-1">on Port {potServerPort}</span>
) : null}
</Label>
</div>
<div className="disable-innertube">
<h3 className="font-semibold">Disable Innertube</h3>
<p className="text-xs text-muted-foreground mb-3">Disable the usage of innertube api for potoken generation (falls back to legacy mode, use only if normal potoken is not working)</p>
<div className="flex items-center space-x-2">
<Switch
id="disable-innertube"
checked={disableInnertube}
onCheckedChange={(checked) => saveSettingsKey('disable_innertube', checked)}
disabled={useCustomCommands || !usePotoken}
/>
</div>
</div>
<div className="pot-server-port">
<h3 className="font-semibold">POT Server Port</h3>
<p className="text-xs text-muted-foreground mb-3">Change neodlp proof-of-origin token server port</p>
<div className="flex flex-col gap-2">
<Form {...potServerPortForm}>
<form onSubmit={potServerPortForm.handleSubmit(handlePotServerPortSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField
control={potServerPortForm.control}
name="port"
disabled={!usePotoken || useCustomCommands || isChangingPotServerPort || isStartingPotServer}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<NumberInput
className="w-full"
placeholder="Enter port number"
min={0}
readOnly={useCustomCommands}
{...field}
/>
</FormControl>
<Label htmlFor="port" className="text-xs text-muted-foreground">(Current: {potServerPort}) (Default: 4416, Range: 4000-5000)</Label>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!watchedPotServerPort || Number(watchedPotServerPort) === potServerPort || Object.keys(potServerPortFormErrors).length > 0 || !usePotoken || useCustomCommands || isChangingPotServerPort || isStartingPotServer}
>
{isChangingPotServerPort ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Changing
</>
) : (
'Change'
)}
</Button>
</form>
</Form>
</div>
</div>
</>
);
}
function AppNotificationSettings() { function AppNotificationSettings() {
const { saveSettingsKey } = useSettings(); const { saveSettingsKey } = useSettings();
@@ -1040,12 +1462,14 @@ function AppNotificationSettings() {
function AppCommandSettings() { function AppCommandSettings() {
const { saveSettingsKey } = useSettings(); const { saveSettingsKey } = useSettings();
const { startPotServer, stopPotServer } = usePotServer();
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger); const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset); const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands); const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
const usePotoken = useSettingsPageStatesStore(state => state.settings.use_potoken);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration); const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
@@ -1123,9 +1547,14 @@ function AppCommandSettings() {
<Switch <Switch
id="use-custom-commands" id="use-custom-commands"
checked={useCustomCommands} checked={useCustomCommands}
onCheckedChange={(checked) => { onCheckedChange={async(checked) => {
saveSettingsKey('use_custom_commands', checked) saveSettingsKey('use_custom_commands', checked)
resetDownloadConfiguration(); resetDownloadConfiguration();
if (checked && usePotoken) {
await stopPotServer();
} else if (!checked && usePotoken) {
await startPotServer();
}
}} }}
/> />
<Label htmlFor="use-custom-commands">Use Custom Commands</Label> <Label htmlFor="use-custom-commands">Use Custom Commands</Label>
@@ -1260,6 +1689,9 @@ function AppDebugSettings() {
} }
function AppInfoSettings() { function AppInfoSettings() {
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
const isAppimage = useEnvironmentStore(state => state.isAppimage);
const appVersion = useSettingsPageStatesStore(state => state.appVersion); const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const binDepsList = [ const binDepsList = [
@@ -1268,6 +1700,7 @@ function AppInfoSettings() {
{ key: 'ffprobe', name: 'FFprobe', desc: 'Multimedia stream analyzer for retrieving media information', url: 'https://ffmpeg.org/ffprobe.html', license: 'LGPLv2.1+', licenseUrl: 'https://ffmpeg.org/legal.html' }, { key: 'ffprobe', name: 'FFprobe', desc: 'Multimedia stream analyzer for retrieving media information', url: 'https://ffmpeg.org/ffprobe.html', license: 'LGPLv2.1+', licenseUrl: 'https://ffmpeg.org/legal.html' },
{ key: 'deno', name: 'Deno', desc: 'The modern JavaScript/TypeScript runtime', url: 'https://deno.land/', license: 'MIT', licenseUrl: 'https://github.com/denoland/deno/blob/main/LICENSE.md' }, { key: 'deno', name: 'Deno', desc: 'The modern JavaScript/TypeScript runtime', url: 'https://deno.land/', license: 'MIT', licenseUrl: 'https://github.com/denoland/deno/blob/main/LICENSE.md' },
{ key: 'aria2', name: 'Aria2', desc: 'Lightweight multi-protocol & multi-source download utility', url: 'https://aria2.github.io/', license: 'GPLv2+', licenseUrl: 'https://github.com/aria2/aria2/blob/master/COPYING' }, { key: 'aria2', name: 'Aria2', desc: 'Lightweight multi-protocol & multi-source download utility', url: 'https://aria2.github.io/', license: 'GPLv2+', licenseUrl: 'https://github.com/aria2/aria2/blob/master/COPYING' },
{ Key: 'bgutil-pot-rs', name: 'BgUtils POT Provider (Rust)', desc: 'A high-performance YouTube POT (Proof-of-Origin Token) provider implemented in Rust', url: 'https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs', license: 'GPLv3+', licenseUrl: 'https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/blob/master/LICENSE' },
]; ];
const langDepsList = [ const langDepsList = [
{ key: 'tauri', name: 'Tauri', desc: 'Framework for building cross-platform, tiny and blazing fast binaries', url: 'https://tauri.app/', license: 'MIT, Apache-2.0', licenseUrl: 'https://github.com/tauri-apps/tauri/blob/dev/LICENSE_MIT' }, { key: 'tauri', name: 'Tauri', desc: 'Framework for building cross-platform, tiny and blazing fast binaries', url: 'https://tauri.app/', license: 'MIT, Apache-2.0', licenseUrl: 'https://github.com/tauri-apps/tauri/blob/dev/LICENSE_MIT' },
@@ -1372,6 +1805,35 @@ function AppInfoSettings() {
</Button> </Button>
</Card> </Card>
</div> </div>
<div className="healthcheck">
<h3 className="font-semibold">Health Check</h3>
<p className="text-xs text-muted-foreground mb-3">Ensure everything is working fine</p>
{isFlatpak ? (
<Alert className="">
<TriangleAlert className="size-4 stroke-primary" />
<AlertTitle className="text-sm">Flatpak Sandbox Detected!</AlertTitle>
<AlertDescription className="text-xs">
It looks like you are running NeoDLP in a Flatpak sandbox. Some features like changing download folder, revealing completed downloads in explorer and automatic yt-dlp updates are not available in Flatpak due to sandbox restrictions. To use these features, please install the native build (DEB, RPM or AUR) of NeoDLP.
</AlertDescription>
</Alert>
) : isAppimage ? (
<Alert className="">
<TriangleAlert className="size-4 stroke-primary" />
<AlertTitle className="text-sm">Appimage Environment Detected!</AlertTitle>
<AlertDescription className="text-xs">
Looks like you are using NeoDLP Appimage. NeoDLP's browser integration features are not available on Appimage environment due to it's limitations. To use NeoDLP's browser integration features please install the native build (DEB, RPM or AUR) of NeoDLP.
</AlertDescription>
</Alert>
) : (
<Alert className="">
<CircleCheck className="size-4 stroke-primary" />
<AlertTitle className="text-sm">All Set! Cheers :)</AlertTitle>
<AlertDescription className="text-xs">
NeoDLP is running as normal without any limitations! You should be able to use all the features of NeoDLP without any issues. If you face any problem, feel free to report it to us.
</AlertDescription>
</Alert>
)}
</div>
<div className="bug-report"> <div className="bug-report">
<h3 className="font-semibold">Bug Report</h3> <h3 className="font-semibold">Bug Report</h3>
<p className="text-xs text-muted-foreground mb-3">Noticed any bug or inconsistencies? Report it to help us improve</p> <p className="text-xs text-muted-foreground mb-3">Noticed any bug or inconsistencies? Report it to help us improve</p>
@@ -1442,6 +1904,8 @@ function AppInfoSettings() {
} }
export function ApplicationSettings() { export function ApplicationSettings() {
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
const activeSubAppTab = useSettingsPageStatesStore(state => state.activeSubAppTab); const activeSubAppTab = useSettingsPageStatesStore(state => state.activeSubAppTab);
const setActiveSubAppTab = useSettingsPageStatesStore(state => state.setActiveSubAppTab); const setActiveSubAppTab = useSettingsPageStatesStore(state => state.setActiveSubAppTab);
@@ -1468,6 +1932,8 @@ export function ApplicationSettings() {
{ key: 'network', label: 'Network', icon: Wifi, component: <AppNetworkSettings /> }, { key: 'network', label: 'Network', icon: Wifi, component: <AppNetworkSettings /> },
{ key: 'cookies', label: 'Cookies', icon: Cookie, component: <AppCookiesSettings /> }, { key: 'cookies', label: 'Cookies', icon: Cookie, component: <AppCookiesSettings /> },
{ key: 'sponsorblock', label: 'Sponsorblock', icon: ShieldMinus, component: <AppSponsorblockSettings /> }, { key: 'sponsorblock', label: 'Sponsorblock', icon: ShieldMinus, component: <AppSponsorblockSettings /> },
{ key: 'delay', label: 'Delay', icon: Timer, component: <AppDelaySettings /> },
{ key: 'potoken', label: 'Potoken', icon: KeyRound, component: <AppPoTokenSettings /> },
{ key: 'notifications', label: 'Notifications', icon: BellRing, component: <AppNotificationSettings /> }, { key: 'notifications', label: 'Notifications', icon: BellRing, component: <AppNotificationSettings /> },
{ key: 'commands', label: 'Commands', icon: SquareTerminal, component: <AppCommandSettings /> }, { key: 'commands', label: 'Commands', icon: SquareTerminal, component: <AppCommandSettings /> },
{ key: 'debug', label: 'Debug', icon: Bug, component: <AppDebugSettings /> }, { key: 'debug', label: 'Debug', icon: Bug, component: <AppDebugSettings /> },
@@ -1497,12 +1963,14 @@ export function ApplicationSettings() {
<Switch <Switch
id="ytdlp-auto-update" id="ytdlp-auto-update"
checked={ytDlpAutoUpdate} checked={ytDlpAutoUpdate}
disabled={isFlatpak}
onCheckedChange={(checked) => saveSettingsKey('ytdlp_auto_update', checked)} onCheckedChange={(checked) => saveSettingsKey('ytdlp_auto_update', checked)}
/> />
<Label htmlFor="ytdlp-auto-update">Auto Update</Label> <Label htmlFor="ytdlp-auto-update">Auto Update</Label>
</div> </div>
<Select <Select
value={ytDlpUpdateChannel} value={ytDlpUpdateChannel}
disabled={isFlatpak}
onValueChange={(value) => saveSettingsKey('ytdlp_update_channel', value)} onValueChange={(value) => saveSettingsKey('ytdlp_update_channel', value)}
> >
<SelectTrigger className="w-37.5 ring-0 focus:ring-0"> <SelectTrigger className="w-37.5 ring-0 focus:ring-0">
@@ -1517,7 +1985,7 @@ export function ApplicationSettings() {
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
disabled={ytDlpAutoUpdate || isUpdatingYtDlp || ongoingDownloads.length > 0} disabled={ytDlpAutoUpdate || isUpdatingYtDlp || ongoingDownloads.length > 0 || isFlatpak}
onClick={async () => await updateYtDlp()} onClick={async () => await updateYtDlp()}
> >
{isUpdatingYtDlp ? ( {isUpdatingYtDlp ? (
@@ -1553,7 +2021,7 @@ export function ApplicationSettings() {
</TabsList> </TabsList>
<div className="min-h-full flex flex-col w-full border-l border-border pl-4"> <div className="min-h-full flex flex-col w-full border-l border-border pl-4">
{tabsList.map((tab) => ( {tabsList.map((tab) => (
<TabsContent key={tab.key} value={tab.key} className={clsx("flex flex-col gap-4 min-h-108.75", tab.key === "info" ? "max-w-[80%]" : "max-w-[70%]")}> <TabsContent key={tab.key} value={tab.key} className={clsx("flex flex-col gap-4 min-h-130", tab.key === "info" ? "max-w-[80%]" : "max-w-[70%]")}>
{tab.component} {tab.component}
</TabsContent> </TabsContent>
))} ))}

View File

@@ -1,13 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettingsPageStatesStore } from "@/services/store"; import { useEnvironmentStore, useSettingsPageStatesStore } from "@/services/store";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowDownToLine, ArrowRight, EthernetPort, Loader2, Radio, RotateCw } from "lucide-react"; import { ArrowDownToLine, ArrowRight, EthernetPort, Loader2, Radio, RotateCw } from "lucide-react";
import { useSettings } from "@/helpers/use-settings"; import { useSettings } from "@/helpers/use-settings";
import { Input } from "@/components/ui/input";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
@@ -15,6 +14,8 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from "@/component
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { SlidingButton } from "@/components/custom/slidingButton"; import { SlidingButton } from "@/components/custom/slidingButton";
import clsx from "clsx"; import clsx from "clsx";
import { NumberInput } from "@/components/custom/numberInput";
import { platform } from "@tauri-apps/plugin-os";
const websocketPortSchema = z.object({ const websocketPortSchema = z.object({
port: z.coerce.number<number>({ port: z.coerce.number<number>({
@@ -31,9 +32,12 @@ const websocketPortSchema = z.object({
}); });
function ExtInstallSettings() { function ExtInstallSettings() {
const currentPlatform = platform();
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
const openLink = async (url: string, app: string | null) => { const openLink = async (url: string, app: string | null) => {
try { try {
await invoke('open_file_with_app', { filePath: url, appName: app }).then(() => { await invoke('open_link_with_app', { url: url, appName: app }).then(() => {
toast.info("Opening link", { toast.info("Opening link", {
description: `Opening link with ${app ? app : 'default app'}.`, description: `Opening link with ${app ? app : 'default app'}.`,
}) })
@@ -58,7 +62,7 @@ function ExtInstallSettings() {
<span>Get Now</span> <span>Get Now</span>
</div> </div>
} }
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'chrome')} onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : currentPlatform === "linux" ? 'google-chrome' : 'chrome')}
> >
<span className="font-semibold flex items-center gap-2"> <span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
@@ -75,7 +79,7 @@ function ExtInstallSettings() {
<span>Get Now</span> <span>Get Now</span>
</div> </div>
} }
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'firefox')} onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', isFlatpak ? null : 'firefox')}
> >
<span className="font-semibold flex items-center gap-2"> <span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
@@ -87,11 +91,11 @@ function ExtInstallSettings() {
</SlidingButton> </SlidingButton>
</div> </div>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'msedge')}>Edge</Button> <Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'msedge')}>Edge</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'opera')}>Opera</Button> <Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'opera')}>Opera</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'brave')}>Brave</Button> <Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'brave')}>Brave</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'vivaldi')}>Vivaldi</Button> <Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'vivaldi')}>Vivaldi</Button>
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'zen')}>Zen</Button> <Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', isFlatpak ? null : 'zen')}>Zen</Button>
</div> </div>
<p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed before clicking the link</p> <p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed before clicking the link</p>
</div> </div>
@@ -167,9 +171,10 @@ function ExtPortSettings() {
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full"> <FormItem className="w-full">
<FormControl> <FormControl>
<Input <NumberInput
className="focus-visible:ring-0" className="w-full"
placeholder="Enter port number" placeholder="Enter port number"
min={0}
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -46,7 +46,7 @@ export function AppSidebar() {
]; ];
useEffect(() => { useEffect(() => {
let timeout: NodeJS.Timeout; let timeout: ReturnType<typeof setTimeout>;
if (open) { if (open) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
setShowBadge(true); setShowBadge(true);

View File

@@ -61,8 +61,19 @@ export default function useDownloader() {
log_verbose: LOG_VERBOSE, log_verbose: LOG_VERBOSE,
log_progress: LOG_PROGRESS, log_progress: LOG_PROGRESS,
enable_notifications: ENABLE_NOTIFICATIONS, enable_notifications: ENABLE_NOTIFICATIONS,
download_completion_notification: DOWNLOAD_COMPLETION_NOTIFICATION download_completion_notification: DOWNLOAD_COMPLETION_NOTIFICATION,
use_delay: USE_DELAY,
use_search_delay: USE_SEARCH_DELAY,
delay_mode: DELAY_MODE,
min_sleep_interval: MIN_SLEEP_INTERVAL,
max_sleep_interval: MAX_SLEEP_INTERVAL,
request_sleep_interval: REQUEST_SLEEP_INTERVAL,
delay_playlist_only: DELAY_PLAYLIST_ONLY,
use_potoken: USE_POTOKEN,
disable_innertube: DISABLE_INNERTUBE,
pot_server_port: POT_SERVER_PORT,
} = useSettingsPageStatesStore(state => state.settings); } = useSettingsPageStatesStore(state => state.settings);
const isRunningPotServer = useSettingsPageStatesStore(state => state.isRunningPotServer);
const expectedErrorDownloadIds = useDownloaderPageStatesStore((state) => state.expectedErrorDownloadIds); const expectedErrorDownloadIds = useDownloaderPageStatesStore((state) => state.expectedErrorDownloadIds);
const addErroredDownload = useDownloaderPageStatesStore((state) => state.addErroredDownload); const addErroredDownload = useDownloaderPageStatesStore((state) => state.addErroredDownload);
@@ -103,7 +114,12 @@ export default function useDownloader() {
const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise<RawVideoInfo | null> => { const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise<RawVideoInfo | null> => {
const { url, formatId, playlistIndices, selectedSubtitles, resumeState, downloadConfig } = params; const { url, formatId, playlistIndices, selectedSubtitles, resumeState, downloadConfig } = params;
try { try {
const args = [url, '--dump-single-json', '--no-warnings']; const args = [url, '--dump-single-json'];
if (DEBUG_MODE && LOG_VERBOSE) {
args.push('--verbose');
} else {
args.push('--no-warnings');
}
if (formatId) { if (formatId) {
const isMultipleAudioFormatSelected = formatId.split('+').length > 2; const isMultipleAudioFormatSelected = formatId.split('+').length > 2;
args.push('--format', formatId); args.push('--format', formatId);
@@ -162,21 +178,49 @@ export default function useDownloader() {
args.push('--sponsorblock-mark', sponsorblockMark); args.push('--sponsorblock-mark', sponsorblockMark);
} }
}; };
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_SEARCH_DELAY) {
if (DELAY_MODE === 'auto') {
args.push('--sleep-requests', '1', '--sleep-interval', '10', '--max-sleep-interval', '20');
} else if (DELAY_MODE === 'custom') {
args.push('--sleep-requests', REQUEST_SLEEP_INTERVAL.toString(), '--sleep-interval', MIN_SLEEP_INTERVAL.toString(), '--max-sleep-interval', MAX_SLEEP_INTERVAL.toString());
}
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_POTOKEN) {
if (!isRunningPotServer) {
LOG.warning("NEODLP", "Looks like you want to use PO Token! But, NeoDLP POT Server is not running. PO Token generation will most likely fail!");
}
if (DISABLE_INNERTUBE) {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=http://localhost:${POT_SERVER_PORT};disable_innertube=1`);
} else {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=http://localhost:${POT_SERVER_PORT}`);
}
}
const command = Command.sidecar('binaries/yt-dlp', args); const command = Command.sidecar('binaries/yt-dlp', args);
let jsonOutput = ''; let jsonOutput = '';
return new Promise<RawVideoInfo | null>((resolve) => { return new Promise<RawVideoInfo | null>((resolve) => {
command.stdout.on('data', line => { command.stdout.on('data', line => {
jsonOutput += line; if (line.startsWith('{')) {
jsonOutput = line;
} else {
if (line.trim() !== '') LOG.info('YT-DLP', line);
}
});
command.stderr.on('data', line => {
if (line.trim() !== '') LOG.info('YT-DLP', line);
}); });
command.on('close', async (data) => { command.on('close', async (data) => {
if (data.code !== 0) { if (data.code !== 0) {
console.error(`yt-dlp failed to fetch metadata with code ${data.code}`); console.error(`yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url}`);
LOG.error('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url} (ignore if you manually cancelled)`); LOG.error('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url} (ignore if you manually cancelled)`);
resolve(null);
} else { } else {
LOG.info('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url}`);
}
try { try {
const matchedJson = jsonOutput.match(/{.*}/); const matchedJson = jsonOutput.match(/{.*}/);
if (!matchedJson) { if (!matchedJson) {
@@ -193,7 +237,6 @@ export default function useDownloader() {
LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url}) with error: ${e}`); LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url}) with error: ${e}`);
resolve(null); resolve(null);
} }
}
}); });
command.on('error', error => { command.on('error', error => {
@@ -305,14 +348,41 @@ export default function useDownloader() {
args.push('--output', `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`); args.push('--output', `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`);
} }
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_DELAY) {
if (!DELAY_PLAYLIST_ONLY) {
if (DELAY_MODE === 'auto') {
if (isMultiplePlaylistItems) { if (isMultiplePlaylistItems) {
const playlistLength = playlistIndices.split(',').length; const playlistLength = playlistIndices.split(',').length;
if (playlistLength > 5 && playlistLength < 100) { if (playlistLength <= 5) {
args.push('--sleep-requests', '1', '--sleep-interval', '5', '--max-sleep-interval', '15'); args.push('--sleep-requests', '1', '--sleep-interval', '5', '--max-sleep-interval', '10');
} else if (playlistLength > 5 && playlistLength < 100) {
args.push('--sleep-requests', '1', '--sleep-interval', '10', '--max-sleep-interval', '20');
} else if (playlistLength >= 100 && playlistLength < 500) { } else if (playlistLength >= 100 && playlistLength < 500) {
args.push('--sleep-requests', '1.5', '--sleep-interval', '10', '--max-sleep-interval', '40'); args.push('--sleep-requests', '2', '--sleep-interval', '20', '--max-sleep-interval', '40');
} else if (playlistLength >= 500) { } else if (playlistLength >= 500) {
args.push('--sleep-requests', '2.5', '--sleep-interval', '20', '--max-sleep-interval', '60'); args.push('--sleep-requests', '2', '--sleep-interval', '40', '--max-sleep-interval', '60');
}
} else {
args.push('--sleep-requests', '1', '--sleep-interval', '10', '--max-sleep-interval', '20');
}
} else if (DELAY_MODE === 'custom') {
args.push('--sleep-requests', REQUEST_SLEEP_INTERVAL.toString(), '--sleep-interval', MIN_SLEEP_INTERVAL.toString(), '--max-sleep-interval', MAX_SLEEP_INTERVAL.toString());
}
} else if (DELAY_PLAYLIST_ONLY && isMultiplePlaylistItems) {
if (DELAY_MODE === 'auto') {
const playlistLength = playlistIndices.split(',').length;
if (playlistLength <= 5) {
args.push('--sleep-requests', '1', '--sleep-interval', '5', '--max-sleep-interval', '10');
} else if (playlistLength > 5 && playlistLength < 100) {
args.push('--sleep-requests', '1', '--sleep-interval', '10', '--max-sleep-interval', '20');
} else if (playlistLength >= 100 && playlistLength < 500) {
args.push('--sleep-requests', '2', '--sleep-interval', '20', '--max-sleep-interval', '40');
} else if (playlistLength >= 500) {
args.push('--sleep-requests', '2', '--sleep-interval', '40', '--max-sleep-interval', '60');
}
} else if (DELAY_MODE === 'custom') {
args.push('--sleep-requests', REQUEST_SLEEP_INTERVAL.toString(), '--sleep-interval', MIN_SLEEP_INTERVAL.toString(), '--max-sleep-interval', MAX_SLEEP_INTERVAL.toString());
}
} }
} }
@@ -469,6 +539,17 @@ export default function useDownloader() {
LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`); LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`);
} }
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_POTOKEN) {
if (!isRunningPotServer) {
LOG.warning("NEODLP", "Looks like you want to use PO Token! But, NeoDLP POT Server is not running. PO Token generation will most likely fail!");
}
if (DISABLE_INNERTUBE) {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=http://localhost:${POT_SERVER_PORT};disable_innertube=1`);
} else {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=http://localhost:${POT_SERVER_PORT}`);
}
}
if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) { if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) {
args.push('--continue'); args.push('--continue');
} else { } else {

View File

@@ -0,0 +1,105 @@
import { join, resourceDir, homeDir } from "@tauri-apps/api/path";
import * as fs from "@tauri-apps/plugin-fs";
import { useKvPairs } from "@/helpers/use-kvpairs";
import { useSettingsPageStatesStore } from "@/services/store";
import { Command } from "@tauri-apps/plugin-shell";
import { invoke } from "@tauri-apps/api/core";
interface FileMap {
source: string;
destination: string;
dir: string;
content?: string;
}
export function useLinuxRegisterer() {
const { saveKvPair } = useKvPairs();
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const registerToLinux = async () => {
try {
const isFlatpak = await invoke<boolean>('is_flatpak');
const resourceDirPath = isFlatpak ? '/app/lib/neodlp' : await resourceDir();
const homeDirPath = await homeDir();
const flatpakChromeManifestContent = {
name: "com.neosubhamoy.neodlp",
description: "NeoDLP MsgHost",
path: `${homeDirPath}/.local/bin/neodlp-msghost`,
type: "stdio",
allowed_origins: ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
};
const flatpakFirefoxManifestContent = {
name: "com.neosubhamoy.neodlp",
description: "NeoDLP MsgHost",
path: `${homeDirPath}/.local/bin/neodlp-msghost`,
type: "stdio",
allowed_extensions: ["neodlp@neosubhamoy.com"]
};
const filesToCopy: FileMap[] = [
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_cli.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_cli.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_http.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_http.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
];
const filesToCopyFlatpak: FileMap[] = [
{ source: 'chrome.json', destination: '.config/google-chrome/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: '.config/google-chrome/NativeMessagingHosts/', content: JSON.stringify(flatpakChromeManifestContent) },
{ source: 'chrome.json', destination: '.config/chromium/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: '.config/chromium/NativeMessagingHosts/', content: JSON.stringify(flatpakChromeManifestContent) },
{ source: 'firefox.json', destination: '.mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json', dir: '.mozilla/native-messaging-hosts/', content: JSON.stringify(flatpakFirefoxManifestContent) },
{ source: 'neodlp-msghost', destination: '.local/bin/neodlp-msghost', dir: '.local/bin/' },
];
if (isFlatpak) {
for (const file of filesToCopyFlatpak) {
const sourcePath = await join(resourceDirPath, file.source);
const destinationPath = await join(homeDirPath, file.destination);
const escapedContent = file.content?.replace(/'/g, `'\\''`) || '';
const copyCommand = Command.create('sh', ['-c', `cp "${sourcePath}" "${destinationPath}"`]);
const writeCommand = Command.create('sh', ['-c', `printf '%s' '${escapedContent}' > "${destinationPath}"`]);
if (file.content) {
const writeOutput = await writeCommand.execute();
if (writeOutput.code === 0) {
console.log(`File ${file.destination} created successfully at ${destinationPath}`);
} else {
console.error(`Failed to create file ${file.destination} at ${destinationPath}:`, writeOutput.stderr);
return { success: false, message: 'Failed to register' };
}
} else {
const copyOutput = await copyCommand.execute();
if (copyOutput.code === 0) {
console.log(`File ${file.source} copied successfully to ${destinationPath}`);
} else {
console.error(`Failed to copy file ${file.source} to ${destinationPath}:`, copyOutput.stderr);
return { success: false, message: 'Failed to register' };
}
}
}
} else {
for (const file of filesToCopy) {
const sourcePath = await join(resourceDirPath, file.source);
const destinationDir = await join(homeDirPath, file.dir);
const destinationPath = await join(homeDirPath, file.destination);
const dirExists = await fs.exists(destinationDir);
if (dirExists) {
await fs.copyFile(sourcePath, destinationPath);
console.log(`File ${file.source} copied successfully to ${destinationPath}`);
} else {
await fs.mkdir(destinationDir, { recursive: true })
console.log(`Created dir ${destinationDir}`);
await fs.copyFile(sourcePath, destinationPath);
console.log(`File ${file.source} copied successfully to ${destinationPath}`);
}
}
}
saveKvPair('linux_registered_version', appVersion);
return { success: true, message: 'Registered successfully' }
} catch (error) {
console.error('Error copying files:', error);
return { success: false, message: 'Failed to register' }
}
}
return { registerToLinux };
}

View File

@@ -3,17 +3,26 @@ import * as fs from "@tauri-apps/plugin-fs";
import { useKvPairs } from "@/helpers/use-kvpairs"; import { useKvPairs } from "@/helpers/use-kvpairs";
import { useSettingsPageStatesStore } from "@/services/store"; import { useSettingsPageStatesStore } from "@/services/store";
interface FileMap {
source: string;
destination: string;
dir: string;
}
export function useMacOsRegisterer() { export function useMacOsRegisterer() {
const { saveKvPair } = useKvPairs(); const { saveKvPair } = useKvPairs();
const appVersion = useSettingsPageStatesStore(state => state.appVersion); const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const registerToMac = async () => { const registerToMac = async () => {
try { try {
const filesToCopy = [ const filesToCopy: FileMap[] = [
{ source: 'neodlp-autostart.plist', destination: 'Library/LaunchAgents/com.neosubhamoy.neodlp.plist', dir: 'Library/LaunchAgents/' }, { source: 'neodlp-autostart.plist', destination: 'Library/LaunchAgents/com.neosubhamoy.neodlp.plist', dir: 'Library/LaunchAgents/' },
{ source: 'neodlp-msghost.json', destination: 'Library/Application Support/Google/Chrome/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Google/Chrome/NativeMessagingHosts/' }, { source: 'neodlp-msghost.json', destination: 'Library/Application Support/Google/Chrome/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Google/Chrome/NativeMessagingHosts/' },
{ source: 'neodlp-msghost.json', destination: 'Library/Application Support/Chromium/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Chromium/NativeMessagingHosts/' }, { source: 'neodlp-msghost.json', destination: 'Library/Application Support/Chromium/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Chromium/NativeMessagingHosts/' },
{ source: 'neodlp-msghost-moz.json', destination: 'Library/Application Support/Mozilla/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Mozilla/NativeMessagingHosts/' }, { source: 'neodlp-msghost-moz.json', destination: 'Library/Application Support/Mozilla/NativeMessagingHosts/com.neosubhamoy.neodlp.json', dir: 'Library/Application Support/Mozilla/NativeMessagingHosts/' },
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_cli.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_cli.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
{ source: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_http.py', destination: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/getpot_bgutil_http.py', dir: 'yt-dlp-plugins/bgutil-ytdlp-pot-provider/yt_dlp_plugins/extractor/' },
]; ];
const resourceDirPath = await resourceDir(); const resourceDirPath = await resourceDir();

View File

@@ -0,0 +1,86 @@
import { useSettingsPageStatesStore } from "@/services/store";
import { useLogger } from "@/helpers/use-logger";
import { Command } from "@tauri-apps/plugin-shell";
import { invoke } from "@tauri-apps/api/core";
export default function usePotServer() {
const setIsRunningPotServer = useSettingsPageStatesStore(state => state.setIsRunningPotServer);
const setIsStartingPotServer = useSettingsPageStatesStore(state => state.setIsStartingPotServer);
const potServerPid = useSettingsPageStatesStore(state => state.potServerPid);
const setPotServerPid = useSettingsPageStatesStore(state => state.setPotServerPid);
const potServerPort = useSettingsPageStatesStore(state => state.settings.pot_server_port);
const LOG = useLogger();
const stripAnsiAndLogPrefix = (line: string): string => {
const stripped = line.replace(/\x1b\[\d+m/g, '');
return stripped.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+\w+\s+[\w:]+:\s*/, '');
};
const startPotServer = async (port?: number) => {
const runCommand = Command.sidecar('binaries/neodlp-pot', [
'server',
'--port',
port ? port.toString() : potServerPort.toString(),
]);
try {
setIsStartingPotServer(true);
LOG.info("NEODLP POT-SERVER", `Starting POT Server on port: ${port ?? potServerPort}`);
runCommand.on("close", (data) => {
if (data.code === 0) {
LOG.info("NEODLP POT-SERVER", `POT Server process exited with code: ${data.code}`);
} else {
LOG.error("NEODLP POT-SERVER", `POT Server process exited with code: ${data.code} (ignore if you manually stopped the server)`);
}
setIsRunningPotServer(false);
setPotServerPid(null);
});
runCommand.on("error", (error) => {
LOG.error("NEODLP POT-SERVER", `Error running POT Server: ${error}`);
setIsRunningPotServer(false);
setPotServerPid(null);
});
runCommand.stdout.on("data", (line) => {
const cleanedLine = stripAnsiAndLogPrefix(line).trim();
if (cleanedLine !== '') LOG.info("NEODLP POT-SERVER", cleanedLine);
if (cleanedLine.startsWith("POT server")) {
setIsRunningPotServer(true);
}
});
runCommand.stderr.on("data", (line) => {
const cleanedLine = stripAnsiAndLogPrefix(line).trim();
if (cleanedLine !== '') LOG.error("NEODLP POT-SERVER", cleanedLine);
});
const child = await runCommand.spawn();
setPotServerPid(child.pid);
} catch (error) {
LOG.error("NEODLP POT-SERVER", `Error starting POT Server: ${error}`);
} finally {
setIsStartingPotServer(false);
}
}
const stopPotServer = async () => {
if (!potServerPid) {
LOG.warning("NEODLP POT-SERVER", "No POT Server process found to stop.");
return;
}
try {
LOG.info("NEODLP POT-SERVER", `Stopping POT Server with PID: ${potServerPid}`);
await invoke('kill_all_process', { pid: potServerPid });
LOG.info("NEODLP POT-SERVER", "POT Server stopped successfully.");
setIsRunningPotServer(false);
setPotServerPid(null);
} catch (error) {
LOG.error("NEODLP POT-SERVER", `Error stopping POT Server: ${error}`);
}
}
return { startPotServer, stopPotServer};
}

View File

@@ -17,10 +17,11 @@ export function useYtDlpUpdater() {
const updateYtDlp = async () => { const updateYtDlp = async () => {
const CURRENT_TIMESTAMP = Date.now(); const CURRENT_TIMESTAMP = Date.now();
const isFlatpak = await invoke<boolean>('is_flatpak');
setIsUpdatingYtDlp(true); setIsUpdatingYtDlp(true);
LOG.info('NEODLP', 'Updating yt-dlp to latest version'); LOG.info('NEODLP', 'Updating yt-dlp to latest version');
try { try {
const command = currentPlatform === 'linux' ? Command.create('pkexec', ['yt-dlp', '--update-to', ytDlpUpdateChannel]) : Command.sidecar('binaries/yt-dlp', ['--update-to', ytDlpUpdateChannel]); const command = currentPlatform === 'linux' && !isFlatpak ? Command.create('pkexec', ['yt-dlp', '--update-to', ytDlpUpdateChannel]) : Command.sidecar('binaries/yt-dlp', ['--update-to', ytDlpUpdateChannel]);
const output = await command.execute(); const output = await command.execute();
if (output.code === 0) { if (output.code === 0) {
console.log("yt-dlp updated successfully:", output.stdout); console.log("yt-dlp updated successfully:", output.stdout);

View File

@@ -1,194 +0,0 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -9,6 +9,7 @@ import { useSettings } from "@/helpers/use-settings";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { ExtensionSettings } from "@/components/pages/settings/extensionSettings"; import { ExtensionSettings } from "@/components/pages/settings/extensionSettings";
import { ApplicationSettings } from "@/components/pages/settings/applicationSettings"; import { ApplicationSettings } from "@/components/pages/settings/applicationSettings";
import usePotServer from "@/helpers/use-pot-server";
export default function SettingsPage() { export default function SettingsPage() {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@@ -17,10 +18,12 @@ export default function SettingsPage() {
const setActiveTab = useSettingsPageStatesStore(state => state.setActiveTab); const setActiveTab = useSettingsPageStatesStore(state => state.setActiveTab);
const isUsingDefaultSettings = useSettingsPageStatesStore(state => state.isUsingDefaultSettings); const isUsingDefaultSettings = useSettingsPageStatesStore(state => state.isUsingDefaultSettings);
const isRunningPotServer = useSettingsPageStatesStore(state => state.isRunningPotServer);
const appTheme = useSettingsPageStatesStore(state => state.settings.theme); const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
const appColorScheme = useSettingsPageStatesStore(state => state.settings.color_scheme); const appColorScheme = useSettingsPageStatesStore(state => state.settings.color_scheme);
const { resetSettings } = useSettings(); const { resetSettings } = useSettings();
const { stopPotServer } = usePotServer();
useEffect(() => { useEffect(() => {
const updateTheme = async () => { const updateTheme = async () => {
@@ -60,8 +63,11 @@ export default function SettingsPage() {
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={ <AlertDialogAction onClick={
() => { async () => {
resetSettings() resetSettings();
if (isRunningPotServer) {
await stopPotServer();
}
} }
}>Reset</AlertDialogAction> }>Reset</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -1,4 +1,4 @@
import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, KvPairsStatesStore, LibraryPageStatesStore, LogsStore, SettingsPageStatesStore } from '@/types/store'; import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, EnvironmentStore, KvPairsStatesStore, LibraryPageStatesStore, LogsStore, SettingsPageStatesStore } from '@/types/store';
import { create } from 'zustand'; import { create } from 'zustand';
export const useBasePathsStore = create<BasePathsStore>((set) => ({ export const useBasePathsStore = create<BasePathsStore>((set) => ({
@@ -211,6 +211,16 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
enable_notifications: false, enable_notifications: false,
update_notification: true, update_notification: true,
download_completion_notification: false, download_completion_notification: false,
use_delay: true,
use_search_delay: false,
delay_mode: 'auto',
min_sleep_interval: 10,
max_sleep_interval: 20,
request_sleep_interval: 1,
delay_playlist_only: true,
use_potoken: false,
disable_innertube: false,
pot_server_port: 4416,
// extension settings // extension settings
websocket_port: 53511 websocket_port: 53511
}, },
@@ -223,6 +233,10 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
appUpdateDownloadProgress: 0, appUpdateDownloadProgress: 0,
formResetTrigger: 0, formResetTrigger: 0,
resetAcknowledgements: 0, resetAcknowledgements: 0,
isRunningPotServer: false,
isStartingPotServer: false,
isChangingPotServerPort: false,
potServerPid: null,
setActiveTab: (tab) => set(() => ({ activeTab: tab })), setActiveTab: (tab) => set(() => ({ activeTab: tab })),
setActiveSubAppTab: (tab) => set(() => ({ activeSubAppTab: tab })), setActiveSubAppTab: (tab) => set(() => ({ activeSubAppTab: tab })),
setActiveSubExtTab: (tab) => set(() => ({ activeSubExtTab: tab })), setActiveSubExtTab: (tab) => set(() => ({ activeSubExtTab: tab })),
@@ -282,6 +296,16 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
enable_notifications: false, enable_notifications: false,
update_notification: true, update_notification: true,
download_completion_notification: false, download_completion_notification: false,
use_delay: true,
use_search_delay: false,
delay_mode: 'auto',
min_sleep_interval: 10,
max_sleep_interval: 20,
request_sleep_interval: 1,
delay_playlist_only: true,
use_potoken: false,
disable_innertube: false,
pot_server_port: 4416,
// extension settings // extension settings
websocket_port: 53511 websocket_port: 53511
}, },
@@ -301,12 +325,17 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
acknowledgeFormReset: () => set((state) => ({ acknowledgeFormReset: () => set((state) => ({
resetAcknowledgements: state.resetAcknowledgements + 1 resetAcknowledgements: state.resetAcknowledgements + 1
})), })),
setIsRunningPotServer: (isRunning) => set(() => ({ isRunningPotServer: isRunning })),
setIsStartingPotServer: (isStarting) => set(() => ({ isStartingPotServer: isStarting })),
setIsChangingPotServerPort: (isChanging) => set(() => ({ isChangingPotServerPort: isChanging })),
setPotServerPid: (pid) => set(() => ({ potServerPid: pid }))
})); }));
export const useKvPairsStatesStore = create<KvPairsStatesStore>((set) => ({ export const useKvPairsStatesStore = create<KvPairsStatesStore>((set) => ({
kvPairs: { kvPairs: {
ytdlp_update_last_check: null, ytdlp_update_last_check: null,
macos_registered_version: null macos_registered_version: null,
linux_registered_version: null
}, },
setKvPairsKey: (key, value) => set((state) => ({ setKvPairsKey: (key, value) => set((state) => ({
kvPairs: { kvPairs: {
@@ -323,3 +352,12 @@ export const useLogsStore = create<LogsStore>((set) => ({
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })), addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
clearLogs: () => set(() => ({ logs: [] })) clearLogs: () => set(() => ({ logs: [] }))
})); }));
export const useEnvironmentStore = create<EnvironmentStore>((set) => ({
isFlatpak: false,
isAppimage: false,
appDirPath: null,
setIsFlatpak: (isFlatpak) => set(() => ({ isFlatpak })),
setIsAppimage: (isAppimage) => set(() => ({ isAppimage })),
setAppDirPath: (path) => set(() => ({ appDirPath: path }))
}));

View File

@@ -6,4 +6,5 @@ export interface KvStoreTable {
export interface KvStore { export interface KvStore {
ytdlp_update_last_check: number | null; ytdlp_update_last_check: number | null;
macos_registered_version: string | null; macos_registered_version: string | null;
linux_registered_version: string | null;
} }

View File

@@ -54,6 +54,16 @@ export interface Settings {
enable_notifications: boolean; enable_notifications: boolean;
update_notification: boolean; update_notification: boolean;
download_completion_notification: boolean; download_completion_notification: boolean;
use_delay: boolean;
use_search_delay: boolean;
delay_mode: string;
min_sleep_interval: number;
max_sleep_interval: number;
request_sleep_interval: number;
delay_playlist_only: boolean;
use_potoken: boolean;
disable_innertube: boolean;
pot_server_port: number;
// extension settings // extension settings
websocket_port: number; websocket_port: number;
} }

View File

@@ -110,6 +110,10 @@ export interface SettingsPageStatesStore {
appUpdateDownloadProgress: number; appUpdateDownloadProgress: number;
formResetTrigger: number; formResetTrigger: number;
resetAcknowledgements: number; resetAcknowledgements: number;
isRunningPotServer: boolean;
isStartingPotServer: boolean;
isChangingPotServerPort: boolean;
potServerPid: number | null;
setActiveTab: (tab: string) => void; setActiveTab: (tab: string) => void;
setActiveSubAppTab: (tab: string) => void; setActiveSubAppTab: (tab: string) => void;
setActiveSubExtTab: (tab: string) => void; setActiveSubExtTab: (tab: string) => void;
@@ -130,6 +134,10 @@ export interface SettingsPageStatesStore {
setAppUpdateDownloadProgress: (progress: number) => void; setAppUpdateDownloadProgress: (progress: number) => void;
triggerFormReset: () => void; triggerFormReset: () => void;
acknowledgeFormReset: () => void; acknowledgeFormReset: () => void;
setIsRunningPotServer: (isRunning: boolean) => void;
setIsStartingPotServer: (isStarting: boolean) => void;
setIsChangingPotServerPort: (isChanging: boolean) => void;
setPotServerPid: (pid: number | null) => void;
} }
export interface KvPairsStatesStore { export interface KvPairsStatesStore {
@@ -144,3 +152,12 @@ export interface LogsStore {
addLog: (log: Log) => void; addLog: (log: Log) => void;
clearLogs: () => void; clearLogs: () => void;
} }
export interface EnvironmentStore {
isFlatpak: boolean;
isAppimage: boolean;
appDirPath: string | null;
setIsFlatpak: (isFlatpak: boolean) => void;
setIsAppimage: (isAppimage: boolean) => void;
setAppDirPath: (path: string) => void;
}

View File

@@ -348,7 +348,7 @@ export const determineFileType = (
const audioCodec = (acodec || '').toLowerCase(); const audioCodec = (acodec || '').toLowerCase();
const isNone = (str: string): boolean => { const isNone = (str: string): boolean => {
return ['none', 'n/a', '-', ''].includes(str); return ['none', 'auto', 'n/a', '-', ''].includes(str);
}; };
const hasVideo = !isNone(videoCodec); const hasVideo = !isNone(videoCodec);
@@ -591,7 +591,7 @@ export const getMergedBestFormat = (
} else { } else {
return { return {
...baseFormat, ...baseFormat,
format: 'Best Video (Automatic)', format: 'Best Quality (Auto)',
format_id: 'best', format_id: 'best',
format_note: 'auto', format_note: 'auto',
ext: 'auto', ext: 'auto',