1
1
mirror of https://github.com/neosubhamoy/neodlp.git synced 2026-02-05 01:52:22 +05:30

48 Commits

78 changed files with 6206 additions and 3132 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

3
.gitattributes vendored
View File

@@ -1,2 +1,3 @@
* text=auto eol=lf
src-tauri/binaries/* filter=lfs diff=lfs merge=lfs -text
src-tauri/resources/binaries/* filter=lfs diff=lfs merge=lfs -text

16
.github/banner.svg vendored Normal file
View File

@@ -0,0 +1,16 @@
<svg width="600" height="130" viewBox="0 0 600 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_2)">
<path d="M86.9062 11H21.0938C9.44399 11 0 20.444 0 32.0938V97.9062C0 109.556 9.44399 119 21.0938 119H86.9062C98.556 119 108 109.556 108 97.9062V32.0938C108 20.444 98.556 11 86.9062 11Z" fill="url(#paint0_linear_3_2)"/>
<path d="M55.8196 96.5455C54.7881 97.5856 53.1065 97.5856 52.075 96.5455L27.028 71.2863C25.3778 69.6221 26.5566 66.793 28.9002 66.793H78.9943C81.3379 66.793 82.5168 69.6221 80.8666 71.2863L55.8196 96.5455Z" fill="#FAFAFA"/>
<path d="M67.8164 34.4141H40.0781C38.6219 34.4141 37.4414 35.5946 37.4414 37.0508V68.2695C37.4414 69.7257 38.6219 70.9062 40.0781 70.9062H67.8164C69.2726 70.9062 70.4531 69.7257 70.4531 68.2695V37.0508C70.4531 35.5946 69.2726 34.4141 67.8164 34.4141Z" fill="#FAFAFA"/>
</g>
<defs>
<linearGradient id="paint0_linear_3_2" x1="13.6582" y1="26.6621" x2="97.1367" y2="102.02" gradientUnits="userSpaceOnUse">
<stop stop-color="#4444FF"/>
<stop offset="1" stop-color="#FF43D0"/>
</linearGradient>
<clipPath id="clip0_3_2">
<rect width="108" height="108" fill="white" transform="translate(0 11)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

138
.github/mockup.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 541 KiB

View File

@@ -14,16 +14,14 @@ jobs:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin --config ./src-tauri/tauri.macos-aarch64.conf.json'
arch: 'aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin --config ./src-tauri/tauri.macos-x86_64.conf.json'
arch: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
arch: ''
args: '--target x86_64-unknown-linux-gnu --config ./src-tauri/tauri.linux-x86_64.conf.json'
- platform: 'ubuntu-22.04-arm'
args: '--target aarch64-unknown-linux-gnu --config ./src-tauri/tauri.linux-aarch64.conf.json'
- platform: 'windows-latest'
args: ''
arch: ''
runs-on: ${{ matrix.platform }}
steps:
- name: 🚚 Checkout repository
@@ -32,7 +30,7 @@ jobs:
lfs: true
- name: 🛠️ Install dependencies
if: matrix.platform == 'ubuntu-22.04'
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
@@ -102,7 +100,6 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_ARCH: ${{ matrix.arch }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.github/workflows/.secrets
# Editor directories and files
.vscode/*

View File

@@ -1,26 +1,45 @@
### ✨ Changelog
- Migrated to React 19, TailwindCSS 4 and ShadcnUI 2.6
- Added new 'Extension' tab
- Fixed: Default download directory not updating
- Fixed: MacOS large dock icon (#1)
- Other minor fixes and improvements
- Added per-download configuration tweaks in downloader
- Added cookies support
- Added sponsorblock support (mark/remove segments)
- Added custom yt-dlp command support
- Added aria2 support (macos users need to install aria2 via homebrew to use this feature)
- Added force ipv protocol option in settings
- Added filename template option in settings
- Added real-time app session log viewer (monitor detailed yt-dlp logs)
- Improved download resume persistence (now more essential settings are preserved on resume)
- Added quick search button for completed downloads in library
- Fixed completed download sorting order (last completed on top)
- Ships with deno javascript runtime (as per new yt-dlp requirement)
- Added new linux arm64 builds (deb, rpm)
- Lots of other fixes and improvements
### 📝 Notes
> ⚠️ Linux Users: Make sure yt-dlp is not installed in your distro (otherwise you will get package installation conflict)
> **🔴 DANGER:** 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.
> **⚠️ WARNING:** Linux users make sure `yt-dlp`, `ffmpeg`, `ffprobe` 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)
> This is an Un-Signed Build (Windows doesn't trust this Certificate so, it may flag this as malicious software, in that case, disable Windows SmartScreen and Defender, install it, and then re-enable them)
> This is an Un-Signed Build (MacOS doesn't trust this Certificate so, it may flag this as from 'unverified developer' and prevent it from opening, in that case, open Settings and allow it from 'Settings > Privacy and Security' section to get started)
### 📦 Shipped Binaries
| yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno |
| :---- | :---- | :---- | :---- | :---- |
| v2025.10.01.232815 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.5.3|
### ⬇️ Download Section
| Arch\OS | Windows (msi) ⬆️ | Windows (exe) ⬆️ | Linux (deb) | Linux (rpm) | MacOS (dmg) ⬆️ | MacOS (app) ⬆️ |
| :---- | :---- | :---- | :---- | :---- | :---- | :---- |
| x86_64 | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_en-US.msi) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.x86_64.rpm) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_x64.app.tar.gz) |
| ARM64 | N/A | N/A | N/A | N/A | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_aarch64.app.tar.gz) |
| ARM64 | N/A | 🪟 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_arm64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.aarch64.rpm) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_aarch64.app.tar.gz) |
> ⬆️ icon indicates this packaging format supports in-built app-updater
> 🪟 Windows x86_64 binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact)
> ⚠️ MacOS ARM64 binary downloads are experimental and may not open on Apple Silicon Macs if downloaded from browser (You will get 'Damaged File' error) it's because the binaries are not signed (signing MacOS binaries requires 99$/year Apple Developer Account subscription, which I can't afford RN!) and Apple Silicon Macs don't allow unsigned apps (downloaded from browser) to be installed on the system. If you want to use NeoDLP on your Apple Silicon Macs, you can simply use the command line [Curl-Bash Installer](https://neodlp.neosubhamoy.com/download) (Recommended) -OR- [compile it from source](https://github.com/neosubhamoy/neodlp?tab=readme-ov-file#%EF%B8%8F-contributing--building-from-source) in your Mac

109
README.md
View File

@@ -1,42 +1,99 @@
![NeoDLP](./.github/banner.svg)
# NeoDLP - (Neo Downloader Plus)
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration
[![status](https://img.shields.io/badge/status-active-brightgreen.svg?style=flat)](https://github.com/neosubhamoy/neodlp)
[![github tag](https://img.shields.io/github/v/tag/neosubhamoy/neodlp?color=yellow)](https://github.com/neosubhamoy/neodlp)
[![github downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total)](https://github.com/neosubhamoy/neodlp/releases)
[![PRs](https://img.shields.io/badge/PRs-welcome-blue.svg?style=flat)](https://github.com/neosubhamoy/neodlp)
> **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!**
[![Packaging status](https://repology.org/badge/vertical-allrepos/neodlp.svg)](https://repology.org/project/neodlp/versions)
### ✨ 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 in your preffered format (MP4, WEBM, MKV, MP3 etc.)
- Supports both Video and Playlist download
- Supports Combining Video, Audio streams of your choice
- Supports Multi-Language Subtitle/Caption (CC) embeding
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
- SponsorBlock support (mark/remove video segments)
- Network controls (proxy, rate limit etc.)
- Highly customizable and many more...😉
### 🧩 Browser Integration
You can integrate NeoDLP with your favourite browser (any Chrome/Chromium/Firefox based browser) Just, install [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension) to get started!
After installing the extension you can do the following directly from the browser:
- Quick Search (search current browser address with NeoDLP) (via pressing keyboard shortcut `ALT`+`SHIFT`+`Q`, You can also change this shortcut key combo from browser settings)
- Right Click Context Menu Action (Download with Neo Downloader Plus - Link, Selection, Media Source)
### 👀 Sneak Peek
![NeoDLP-Mockup](./.github/mockup.svg)
### 💻 Supported Platforms
- Windows (10 / 11)
- Linux (Debian / Fedora / Arch Linux base)
- MacOS (>10.3)
- MacOS (>11)
### 🌐 Supported Sites
> ⚠️ **NOTE:** Though most linux (debian/fedora/arch base) distros are supported but not all packages are tested on all these platforms, to save some time (and brain cells) and ship the software as fast as possible! (Currently only the debian package is tested on Ubuntu 24.04 LTS - So, other linux packages may have issues, test it yourself and feel free to report issues if you found one)
- All [Supported Sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) by [yt-dlp](https://github.com/yt-dlp/yt-dlp) **(2.5K+)**
### 🤝 External Dependencies
### 🧩 External Dependencies
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The core CLI Tool used to download Video/Audio from the Web
- [FFmpeg](https://www.ffmpeg.org) - Used for Video/Audio Post-processing
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) - The core CLI tool used to download video/audio from the web (Hero of the show 😎)
- [FFmpeg & FFprobe](https://www.ffmpeg.org) - Used for video/audio post-processing
- [Aria2](https://aria2.github.io) - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds)
- [Deno](https://deno.com) - 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))
### ⬇️ Download and Installation
1. Download the latest [NeoDLP](https://github.com/neosubhamoy/neodlp/releases/latest) release based on your OS and CPU Architecture then install it or install it directly from an available distribution channel
1. Download the latest [NeoDLP](https://github.com/neosubhamoy/neodlp/releases/latest) release based on your OS and CPU Architecture, then install it! -OR- Install it directly from an available distribution channel (listed below)
| Arch\OS | Windows | Linux | MacOS |
| :---- | :---- | :---- | :---- |
| x86_64 | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
| ARM64 | ❌ N/A | ❌ N/A | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
| ARM64 | ✅ Emulation | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
> 📌 **NOTE:** x86_64 Windows binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact)
| Platform (OS) | Distribution Channel | Installation Command / Instruction |
| :---- | :---- | :---- |
| Windows x86_64 | WinGet | `winget install neodlp` |
| MacOS Universal | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
| Windows x86_64 / ARM64 | WinGet | `winget install neodlp` |
| MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
| Linux x86_64 (Arch Linux) | AUR | `yay -S neodlp` |
### 💝 Support the Development
NeoDLP is and will be always FREE to Use and Open-Sourced for Everyone. On the other hand the developent process of NeoDLP takes lots of time, effort and even sometimes money! So, if you appriciate my work and have the ability to donate, then please consider supporting the development by donating (even a very small donation matters and helps NeoDLP to be a better product!) Your support is the key to my motivation...🤗
<a href="https://buymeacoffee.com/neosubhamoy" target="_blank" title="buymeacoffee">
<img src="https://iili.io/JoQ0zN9.md.png" alt="buymeacoffee-orange-badge" style="width: 150px;">
</a>
<br></br>
> 📌 **NOTE:** You can also donate via UPI by sending donations to this UPI ID directly: **subhamoybiswas636-2@oksbi**
### 🪜 Roadmap
- [x] Add support for yt-dlp
- [x] Add basic settings and customization
- [x] Integrate with browsers
- [x] Add aria2c support
- [ ] Add more advanced settings and achive stability **(ongoing)**
- [ ] Add media converter
- [ ] Add multiple downloader engines
- [ ] Add advanced web extractor
- [ ] Add more cool stuffs 😉
### ⚡ Technologies Used
![Tauri](https://img.shields.io/badge/tauri-%2324C8DB.svg?style=for-the-badge&logo=tauri&logoColor=%23FFFFFF)
@@ -53,14 +110,22 @@ Want to be part of this? Feel free to contribute...!! Pull Requests are always w
* Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform
1. Fork this repo in your github account.
2. Git clone the forked repo in your local machine.
3. Install Node.js dependencies: `npm install`
4. Run development / build process
> ⚠️ Make sure to run the build command once before running the dev command for the first time to avoid build time errors
3. Create a git branch (related to the feature you are working on) (Optional - Recommended)
4. Install Node.js dependencies: `npm install`
5. Run development / build process
> ⚠️ **IMPORTANT:** Make sure to run the build command once before running the dev command for the first time to avoid compile time errors
```code
# for windows and linux users
# for windows users
npm run tauri dev # for development
npm run tauri build # for production build
# for linux users
npm run tauri dev -- --config "./src-tauri/tauri.linux-aarch64.conf.json" # for ARM64 devices, development
npm run tauri build -- --config "./src-tauri/tauri.linux-aarch64.conf.json" # for ARM64 devices, production build
npm run tauri dev -- --config "./src-tauri/tauri.linux-x86_64.conf.json" # for x64 devices, development
npm run tauri build -- --config "./src-tauri/tauri.linux-x86_64.conf.json" # for x64 devices, production build
# for macOS users (based on cpu architecture)
npm run tauri dev -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, development
npm run tauri build -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, production build
@@ -68,10 +133,20 @@ npm run tauri build -- --config "./src-tauri/tauri.macos-aarch64.conf.json"
npm run tauri dev -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, development
npm run tauri build -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, production build
```
5. Do the changes, Send a Pull Request with proper Description (NOTE: Pull Requests Without Proper Description will be Rejected)
6. Do the changes, Send a Pull Request with proper Description (NOTE: Pull Requests Without Proper Description will be Rejected)
**⭕ Noticed any Bugs or Want to give us some suggetions? Always feel free to open a GitHub Issue. We would love to hear from you...!!**
### ⭕ Bug Report
Noticed any Bug? or Want to give me some suggetion? Always feel free to open a [GitHub Issue](https://github.com/neosubhamoy/neodlp/issues). I would love to hear from you...!!
### 💫 Credits
- NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02)
- Aria2 Windows x86_64 and Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds)
### 📝 License
NeoDLP is Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any attribution and extra permissions.
****
An Open Sourced Project - Developed with ❤️ by **Subhamoy**

View File

@@ -9,7 +9,6 @@ const __dirname = path.dirname(__filename);
// Define array of binary source directories
const binSrcDirs = [
path.join(__dirname, 'src-tauri', 'binaries'),
path.join(__dirname, 'src-tauri', 'resources', 'binaries'),
];
function makeFilesExecutable() {

2025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "neodlp",
"private": true,
"version": "0.1.1",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,78 +10,79 @@
"tauri": "tauri"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-query-devtools": "^5.80.7",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-sql": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.7.1",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "^2.3.1",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.1",
"@tauri-apps/plugin-sql": "^2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.515.0",
"lucide-react": "^0.545.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.7.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-resizable-panels": "^3.0.2",
"react-router-dom": "^7.6.2",
"recharts": "^2.15.3",
"sonner": "^2.0.5",
"react": "^19.2.0",
"react-day-picker": "^9.11.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.64.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.9.3",
"recharts": "^3.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"ulid": "^3.0.1",
"vaul": "^1.1.2",
"zod": "^3.25.64",
"zustand": "^5.0.5"
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@tailwindcss/vite": "^4.1.10",
"@tauri-apps/cli": "^2",
"@types/node": "^24.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"postcss": "^8.5.5",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"@tailwindcss/postcss": "^4.1.14",
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2.8.4",
"@types/node": "^24.7.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.1.9"
}
}

1887
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "neodlp"
version = "0.1.1"
version = "0.3.0"
description = "NeoDLP"
authors = ["neosubhamoy <hey@neosubhamoy.com>"]
edition = "2021"
@@ -27,7 +27,7 @@ tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "*"
sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] }
base64 = "0.22"
directories = "5.0"
directories = "6.0"
futures-util = "0.3"
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9397aac0de54c8c15b8166486eb80bfe27937bd6d6b6af4bb8383b155213bec1
size 6100888

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cca868da48a85c13a56ccac4dfa8c098f7ed799786a9eaf88248221dbb785bb9
size 8089088

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36f66dab69edcc44255d0dba90c93f5aa4a304ec60c7136d8c279dfc89c23e1d
size 9666624

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1037b241d626483d0048c5ef459aa9fa13cd6bc2bc57d2c7a2aaf7e3b7515f78
size 91614816

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0716e9b52129cb0e43aa33064f641788650a2162681780590ec67dbca419d74f
size 103264328

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:510c9d6ded38568a13dfb0acdaa178f060ee5cacb37aeb9ae500724e460a8e79
size 102745408

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:522e54fc5ca4cb42bc9bb7ed952327481c8b6d20149a8d8e7f7b7a604f9ed199
size 117758936

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df96c25d1804cee25e463d5084a7ded48b6d9d58f8b0d819644a960aa13a7230
size 113525240

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:81eb29b0678f4749cc01934b954f57cdd858c9afce5adf6872394adb5ffb2be2
size 80268888

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e085bde1b47b05d41b23d3f60526067894ba92ce7eba1668c38d460a37c9bb2
size 137315712

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:81eb29b0678f4749cc01934b954f57cdd858c9afce5adf6872394adb5ffb2be2
size 80268888

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d3403afa6d8510aca69d4b3c99619d972fb4651e4787c2de019f1404a139019
size 175262208

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1058b4d448ddcdd2a7e560f928f0c18197c6f8b8cc25a489a95c5e1989b82d88
size 176215272

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2559a63f05e35f2d2d771a2044c1463d6be1e7f0279e67869d5d9bc658556894
size 80086968

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b22c9ad49225bf133ccbb9e2a6494ba2b8c83f4c0514b24e06bdb2c6d6fa7427
size 137124736

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2559a63f05e35f2d2d771a2044c1463d6be1e7f0279e67869d5d9bc658556894
size 80086968

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b49251407a2e2e1a9d00722a89a4163a1f89e128108155585f4ef6c79bcfbd85
size 175064064

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbdef955953b157ee366c162746ccafdb4e2bc1a154e8d6d97bf57751b7e6918
size 176011752

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
size 34391824
oid sha256:c5d493c97561527824d64327b60221bc2646747c30953423e818715f3cea5c3a
size 35709136

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:45213ee9e85de7ecc4ea4edd4862dbf8cd8899d109c469500b1434ac47f80474
size 37262312

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
size 34391824
oid sha256:c5d493c97561527824d64327b60221bc2646747c30953423e818715f3cea5c3a
size 35709136

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c20996d097127884243f4780d929b3769d55418c0efa9bd7a98999f387b5fbed
size 18113133
oid sha256:619d7fa15ff4cdafbf912530799acf32bb4227b363bc8144cb377d82a9ecb6ac
size 18332203

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0aa0afe3d2b32c047b73083f3c8e56081d71bb33fe047357820d51d153d1d54f
size 34608400
oid sha256:ab4161f2fa57092a8d5f4448ed5aef1c814aa38cddc37d068aaf91b0a517c2ea
size 37574216

View File

@@ -15,6 +15,31 @@
"args": true,
"sidecar": true
},
{
"name": "binaries/ffmpeg",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffprobe",
"args": true,
"sidecar": true
},
{
"name": "binaries/aria2c",
"args": true,
"sidecar": true
},
{
"name": "binaries/deno",
"args": true,
"sidecar": true
},
{
"name": "aria2c",
"cmd": "aria2c",
"args": true
},
{
"name": "pkexec",
"cmd": "pkexec",
@@ -29,6 +54,31 @@
"name": "binaries/yt-dlp",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffmpeg",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffprobe",
"args": true,
"sidecar": true
},
{
"name": "binaries/aria2c",
"args": true,
"sidecar": true
},
{
"name": "binaries/deno",
"args": true,
"sidecar": true
},
{
"name": "aria2c",
"cmd": "aria2c",
"args": true
}
]
}

View File

@@ -12,6 +12,6 @@ license = "MIT"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "*"
futures-util = "0.3"
directories = "5.0"
directories = "6.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -6,7 +6,7 @@
<string>com.neosubhamoy.neodlp</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/neodlp.app/Contents/MacOS/neodlp</string>
<string>/Applications/NeoDLP.app/Contents/MacOS/neodlp</string>
<string>--hidden</string>
</array>
<key>RunAtLoad</key>

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
size 79945800

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
size 79945800

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c49b5913c9a107120c86b401af95df7965003f7fc6dbb4436f1f03c8ba391e8b
size 127473664

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ee15e5145c9eb4775c193ab824c592d4ff3744bb7f283f8db29bd3c3c961589
size 79928672

View File

@@ -3,5 +3,5 @@
"description": "NeoDLP MsgHost",
"path": "/usr/bin/neodlp-msghost",
"type": "stdio",
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
}

View File

@@ -3,5 +3,5 @@
"description": "NeoDLP MsgHost",
"path": "/Applications/NeoDLP.app/Contents/Resources/neodlp-msghost",
"type": "stdio",
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
}

View File

@@ -3,5 +3,5 @@
"description": "NeoDLP MsgHost",
"path": "neodlp-msghost.exe",
"type": "stdio",
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
}

View File

@@ -68,5 +68,85 @@ pub fn get_migrations() -> Vec<Migration> {
);
",
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "add_columns_to_downloads",
sql: "
-- Create temporary table with all new columns
CREATE TABLE downloads_temp (
id INTEGER PRIMARY KEY NOT NULL,
download_id TEXT UNIQUE NOT NULL,
download_status TEXT NOT NULL,
video_id TEXT NOT NULL,
format_id TEXT NOT NULL,
subtitle_id TEXT,
queue_index INTEGER,
playlist_id TEXT,
playlist_index INTEGER,
resolution TEXT,
ext TEXT,
abr REAL,
vbr REAL,
acodec TEXT,
vcodec TEXT,
dynamic_range TEXT,
process_id INTEGER,
status TEXT,
progress REAL,
total INTEGER,
downloaded INTEGER,
speed REAL,
eta INTEGER,
filepath TEXT,
filetype TEXT,
filesize INTEGER,
output_format TEXT,
embed_metadata INTEGER NOT NULL DEFAULT 0,
embed_thumbnail INTEGER NOT NULL DEFAULT 0,
sponsorblock_remove TEXT,
sponsorblock_mark TEXT,
use_aria2 INTEGER NOT NULL DEFAULT 0,
custom_command TEXT,
queue_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES video_info (video_id),
FOREIGN KEY (playlist_id) REFERENCES playlist_info (playlist_id)
);
-- Copy all data from original table to temporary table with default values for new columns
INSERT INTO downloads_temp SELECT
id, download_id, download_status, video_id, format_id, subtitle_id,
queue_index, playlist_id, playlist_index, resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status, progress, total,
downloaded, speed, eta, filepath, filetype, filesize,
NULL, -- output_format
0, -- embed_metadata
0, -- embed_thumbnail
NULL, -- sponsorblock_remove
NULL, -- sponsorblock_mark
0, -- use_aria2
NULL, -- custom_command
NULL, -- queue_config
CURRENT_TIMESTAMP, -- created_at
CURRENT_TIMESTAMP -- updated_at
FROM downloads;
-- Drop the original table
DROP TABLE downloads;
-- Rename temporary table to original name
ALTER TABLE downloads_temp RENAME TO downloads;
-- Create trigger for updating updated_at timestamp
CREATE TRIGGER IF NOT EXISTS update_downloads_updated_at
AFTER UPDATE ON downloads
FOR EACH ROW
BEGIN
UPDATE downloads SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
",
kind: MigrationKind::Up,
}]
}

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "NeoDLP",
"mainBinaryName": "neodlp",
"version": "0.1.1",
"version": "0.3.0",
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "npm run dev",
@@ -36,6 +36,9 @@
]
},
"plugins": {
"sql": {
"preload": ["sqlite:database.db"]
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDM0I4ODcyODdGOTM4MDIKUldRQ09QbUhjb2c3UENGY1lFUVdTVWhucmJ4QzdGeW9sU3VHVFlGNWY5anZab2s4SU1rMWFsekMK",
"endpoints": [

View File

@@ -0,0 +1,68 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "NeoDLP",
"width": 1067,
"height": 605,
"visible": false
}
],
"security": {
"csp": null,
"capabilities": [
"default",
"shell-scope"
]
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm"],
"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"
],
"linux": {
"deb": {
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
},
"rpm": {
"epoch": 0,
"release": "1",
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-unknown-linux-gnu && npm run build",
"beforeDevCommand": "cargo build --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,18 +36,19 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/aria2c",
"binaries/deno"
],
"resources": {
"resources/binaries/ffmpeg-x86_64-unknown-linux-gnu": "binaries/ffmpeg-x86_64"
},
"linux": {
"deb": {
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
},
@@ -58,7 +59,7 @@
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
}

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js aarch64-apple-darwin && npm run build",
"beforeDevCommand": "cargo build --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,11 +36,13 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/deno"
],
"resources": {
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/binaries/ffmpeg-aarch64-apple-darwin": "binaries/ffmpeg-aarch64",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-apple-darwin && npm run build",
"beforeDevCommand": "cargo build --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,11 +36,13 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/deno"
],
"resources": {
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/binaries/ffmpeg-x86_64-apple-darwin": "binaries/ffmpeg-x86_64",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"

View File

@@ -2,7 +2,7 @@
"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 && node updateYtDlpBinary.js x86_64-pc-windows-msvc && npm run build",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,10 +36,13 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/aria2c",
"binaries/deno"
],
"resources": {
"resources/binaries/ffmpeg-x86_64-pc-windows-msvc.exe": "binaries/ffmpeg-x86_64.exe",
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
"resources/msghost-manifest/windows/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/windows/firefox.json": "neodlp-msghost-moz.json"

View File

@@ -1,14 +1,13 @@
import { ThemeProvider } from "@/providers/themeProvider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
import { AppContext } from "@/providers/appContextProvider";
import { DownloadState } from "@/types/download";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { arch, exeExtension } from "@tauri-apps/plugin-os";
import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path";
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { determineFileType, generateDownloadId, generateSafeFilePath, generateVideoId, isObjEmpty, parseProgressLine, sanitizeFilename } from "@/utils";
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { determineFileType, generateVideoId, isObjEmpty, parseProgressLine } from "@/utils";
import { Command } from "@tauri-apps/plugin-shell";
import { RawVideoInfo } from "@/types/video";
import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadStatus } from "@/services/mutations";
@@ -25,6 +24,11 @@ import { useNavigate } from "react-router-dom";
import { platform } from "@tauri-apps/plugin-os";
import { useMacOsRegisterer } from "@/helpers/use-macos-registerer";
import useAppUpdater from "@/helpers/use-app-updater";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { toast } from "sonner";
import { useLogger } from "@/helpers/use-logger";
import { DownloadConfiguration } from "@/types/settings";
import { ulid } from "ulid";
export default function App({ children }: { children: React.ReactNode }) {
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
@@ -40,7 +44,8 @@ export default function App({ children }: { children: React.ReactNode }) {
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
// const isUsingDefaultSettings = useSettingsPageStatesStore((state) => state.isUsingDefaultSettings);
const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid);
const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings);
const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey);
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
@@ -53,13 +58,47 @@ export default function App({ children }: { children: React.ReactNode }) {
const YTDLP_UPDATE_CHANNEL = useSettingsPageStatesStore(state => state.settings.ytdlp_update_channel);
const APP_THEME = useSettingsPageStatesStore(state => state.settings.theme);
const MAX_PARALLEL_DOWNLOADS = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
const MAX_RETRIES = useSettingsPageStatesStore(state => state.settings.max_retries);
const DOWNLOAD_DIR = useSettingsPageStatesStore(state => state.settings.download_dir);
const PREFER_VIDEO_OVER_PLAYLIST = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
const STRICT_DOWNLOADABILITY_CHECK = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check);
const USE_PROXY = useSettingsPageStatesStore(state => state.settings.use_proxy);
const PROXY_URL = useSettingsPageStatesStore(state => state.settings.proxy_url);
const USE_RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.use_rate_limit);
const RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.rate_limit);
const VIDEO_FORMAT = useSettingsPageStatesStore(state => state.settings.video_format);
const AUDIO_FORMAT = useSettingsPageStatesStore(state => state.settings.audio_format);
const ALWAYS_REENCODE_VIDEO = useSettingsPageStatesStore(state => state.settings.always_reencode_video);
const EMBED_VIDEO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
const EMBED_AUDIO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
const EMBED_AUDIO_THUMBNAIL = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
const USE_COOKIES = useSettingsPageStatesStore(state => state.settings.use_cookies);
const IMPORT_COOKIES_FROM = useSettingsPageStatesStore(state => state.settings.import_cookies_from);
const COOKIES_BROWSER = useSettingsPageStatesStore(state => state.settings.cookies_browser);
const COOKIES_FILE = useSettingsPageStatesStore(state => state.settings.cookies_file);
const USE_SPONSORBLOCK = useSettingsPageStatesStore(state => state.settings.use_sponsorblock);
const SPONSORBLOCK_MODE = useSettingsPageStatesStore(state => state.settings.sponsorblock_mode);
const SPONSORBLOCK_REMOVE = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove);
const SPONSORBLOCK_MARK = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark);
const SPONSORBLOCK_REMOVE_CATEGORIES = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove_categories);
const SPONSORBLOCK_MARK_CATEGORIES = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark_categories);
const USE_ARIA2 = useSettingsPageStatesStore(state => state.settings.use_aria2);
const USE_FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol);
const FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.force_internet_protocol);
const USE_CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.custom_commands);
const FILENAME_TEMPLATE = useSettingsPageStatesStore(state => state.settings.filename_template);
const isErrored = useDownloaderPageStatesStore((state) => state.isErrored);
const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected);
const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId);
const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored);
const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected);
const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId);
const appWindow = getCurrentWebviewWindow()
const navigate = useNavigate();
const LOG = useLogger();
const { updateYtDlp } = useYtDlpUpdater();
const { registerToMac } = useMacOsRegisterer();
const { checkForAppUpdate } = useAppUpdater();
@@ -83,13 +122,55 @@ export default function App({ children }: { children: React.ReactNode }) {
const hasRunYtDlpAutoUpdateRef = useRef(false);
const isRegisteredToMacOsRef = useRef(false);
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string): Promise<RawVideoInfo | null> => {
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState, downloadConfig?: DownloadConfiguration): Promise<RawVideoInfo | null> => {
try {
const args = [url, '--dump-single-json'];
if (formatId) args.push('-f', formatId);
const args = [url, '--dump-single-json', '--no-warnings'];
if (formatId) args.push('--format', formatId);
if (selectedSubtitles) args.push('--embed-subs', '--sub-lang', selectedSubtitles);
if (playlistIndex) args.push('--playlist-items', playlistIndex);
if (PREFER_VIDEO_OVER_PLAYLIST) args.push('--no-playlist');
if (USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) args.push('--no-playlist');
if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats');
if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats');
if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig?.custom_command) || resumeState?.custom_command) {
let customCommandArgs = null;
if (resumeState?.custom_command) {
customCommandArgs = resumeState.custom_command;
} else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command)) {
let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command);
customCommandArgs = customCommand ? customCommand.args : '';
}
if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' '));
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) {
if (FORCE_INTERNET_PROTOCOL === 'ipv4') {
args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
args.push('--force-ipv6');
}
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
args.push('--cookies', COOKIES_FILE);
}
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark))) {
if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) {
let sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? (
SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default'
) : (SPONSORBLOCK_REMOVE));
args.push('--sponsorblock-remove', sponsorblockRemove);
} else if (SPONSORBLOCK_MODE === 'mark' || resumeState?.sponsorblock_mark) {
let sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? (
SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default'
) : (SPONSORBLOCK_MARK));
args.push('--sponsorblock-mark', sponsorblockMark);
}
};
const command = Command.sidecar('binaries/yt-dlp', args);
let jsonOutput = '';
@@ -99,35 +180,61 @@ export default function App({ children }: { children: React.ReactNode }) {
jsonOutput += line;
});
command.on('close', async () => {
command.on('close', async (data) => {
if (data.code !== 0) {
console.error(`yt-dlp failed to fetch metadata with code ${data.code}`);
LOG.error('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url} (ignore if you manually cancelled)`);
resolve(null);
} else {
try {
const data: RawVideoInfo = JSON.parse(jsonOutput);
resolve(data);
const matchedJson = jsonOutput.match(/{.*}/);
if (!matchedJson) {
console.error(`Failed to match JSON: ${jsonOutput}`);
LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url})`);
resolve(null);
return;
}
const parsedJson: RawVideoInfo = JSON.parse(matchedJson[0]);
resolve(parsedJson);
}
catch (e) {
console.error(`Failed to parse JSON: ${e}`);
LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url}) with error: ${e}`);
resolve(null);
}
}
});
command.on('error', error => {
console.error(`Error fetching metadata: ${error}`);
LOG.error('NEODLP', `Error occurred while fetching metadata for URL: ${url} : ${error}`);
resolve(null);
});
command.spawn().catch(e => {
LOG.info('NEODLP', `Fetching metadata for URL: ${url}, with args: ${args.join(' ')}`);
command.spawn().then(child => {
setSearchPid(child.pid);
}).catch(e => {
console.error(`Failed to spawn command: ${e}`);
LOG.error('NEODLP', `Failed to spawn yt-dlp process for fetching metadata for URL: ${url} : ${e}`);
resolve(null);
});
});
} catch (e) {
console.error(`Failed to fetch metadata: ${e}`);
LOG.error('NEODLP', `Failed to fetch metadata for URL: ${url} : ${e}`);
return null;
}
};
const startDownload = async (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => {
console.log('Starting download:', { url, selectedFormat, selectedSubtitles, resumeState, playlistItems });
const startDownload = async (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => {
LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`);
// set error states to default
setIsErrored(false);
setIsErrorExpected(false);
setErroredDownloadId(null);
console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems });
if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) {
console.error('FFmpeg or download paths not found');
return;
@@ -135,34 +242,61 @@ export default function App({ children }: { children: React.ReactNode }) {
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false;
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null;
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined);
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined, selectedSubtitles, resumeState);
if (!videoMetadata) {
console.error('Failed to fetch video metadata');
toast.error("Download Failed", {
description: "yt-dlp failed to fetch video metadata. Please try again later.",
});
return;
}
console.log('Video Metadata:', videoMetadata);
videoMetadata = isPlaylist ? videoMetadata.entries[0] : videoMetadata;
const fileType = determineFileType(videoMetadata.vcodec, videoMetadata.acodec);
if (fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) {
if (VIDEO_FORMAT !== 'auto' && (fileType === 'video+audio' || fileType === 'video')) videoMetadata.ext = VIDEO_FORMAT;
if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT;
}
let configOutputFormat = null;
if (downloadConfig.output_format && downloadConfig.output_format !== 'auto') {
videoMetadata.ext = downloadConfig.output_format;
configOutputFormat = downloadConfig.output_format;
}
if (resumeState && resumeState.output_format) videoMetadata.ext = resumeState.output_format;
const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain);
const playlistId = isPlaylist ? (resumeState?.playlist_id || generateVideoId(videoMetadata.playlist_id, videoMetadata.webpage_url_domain)) : null;
const downloadId = resumeState?.download_id || generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain);
const tempDownloadPathForYtdlp = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.%(ext)s`);
const tempDownloadPath = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.${videoMetadata.ext}`);
let downloadFilePath = resumeState?.filepath || await join(downloadDirPath, sanitizeFilename(`${videoMetadata.title}_${videoMetadata.resolution || 'unknown'}[${videoMetadata.id}].${videoMetadata.ext}`));
const downloadId = resumeState?.download_id || ulid() /*generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain)*/;
// const tempDownloadPathForYtdlp = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.%(ext)s`);
// const tempDownloadPath = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.${videoMetadata.ext}`);
// let downloadFilePath = resumeState?.filepath || await join(downloadDirPath, sanitizeFilename(`${videoMetadata.title}_${videoMetadata.resolution || 'unknown'}[${videoMetadata.id}].${videoMetadata.ext}`));
let downloadFilePath: string | null = null;
let processPid: number | null = null;
const args = [
url,
'--newline',
'--progress-template',
'status:%(progress.status)s,progress:%(progress._percent_str)s,speed:%(progress.speed)f,downloaded:%(progress.downloaded_bytes)d,total:%(progress.total_bytes)d,eta:%(progress.eta)d',
'--paths',
`temp:${tempDownloadDirPath}`,
'--paths',
`home:${downloadDirPath}`,
'--output',
tempDownloadPathForYtdlp,
'--ffmpeg-location',
ffmpegPath,
'-f',
`${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`,
'--windows-filenames',
'--restrict-filenames',
'--exec',
'after_move:echo Finalpath: {}',
'--format',
selectedFormat,
'--no-mtime',
'--no-warnings',
'--retries',
MAX_RETRIES.toString(),
];
if (selectedSubtitles) {
@@ -173,56 +307,157 @@ export default function App({ children }: { children: React.ReactNode }) {
args.push('--playlist-items', playlistIndex);
}
if (resumeState) {
let customCommandArgs = null;
if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig.custom_command) || resumeState?.custom_command) {
if (resumeState?.custom_command) {
customCommandArgs = resumeState.custom_command;
} else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command)) {
let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command);
customCommandArgs = customCommand ? customCommand.args : '';
}
if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' '));
}
let outputFormat = null;
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) || configOutputFormat || resumeState?.output_format)) {
const format = resumeState?.output_format || configOutputFormat;
if (format) {
outputFormat = format;
} else if (fileType === 'audio' && AUDIO_FORMAT !== 'auto') {
outputFormat = AUDIO_FORMAT;
} else if ((fileType === 'video' || fileType === 'video+audio') && VIDEO_FORMAT !== 'auto') {
outputFormat = VIDEO_FORMAT;
}
const recodeOrRemux = ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--remux-video';
const formatToUse = format || VIDEO_FORMAT;
// Handle video+audio
if (fileType === 'video+audio' && (VIDEO_FORMAT !== 'auto' || format)) {
args.push(ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--merge-output-format', formatToUse);
}
// Handle video only
else if (fileType === 'video' && (VIDEO_FORMAT !== 'auto' || format)) {
args.push(recodeOrRemux, formatToUse);
}
// Handle audio only
else if (fileType === 'audio' && (AUDIO_FORMAT !== 'auto' || format)) {
args.push('--extract-audio', '--audio-format', format || AUDIO_FORMAT);
}
// Handle unknown filetype
else if (fileType === 'unknown' && format) {
if (['mkv', 'mp4', 'webm'].includes(format)) {
args.push(recodeOrRemux, formatToUse);
} else if (['mp3', 'm4a', 'opus'].includes(format)) {
args.push('--extract-audio', '--audio-format', format);
}
}
}
let embedMetadata = 0;
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_metadata || resumeState?.embed_metadata || EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) {
const shouldEmbedForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_VIDEO_METADATA && downloadConfig.embed_metadata === null));
const shouldEmbedForAudio = fileType === 'audio' && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_AUDIO_METADATA && downloadConfig.embed_metadata === null));
const shouldEmbedForUnknown = fileType === 'unknown' && (downloadConfig.embed_metadata || resumeState?.embed_metadata);
if (shouldEmbedForUnknown || shouldEmbedForVideo || shouldEmbedForAudio) {
embedMetadata = 1;
args.push('--embed-metadata');
}
}
let embedThumbnail = 0;
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || (fileType === 'audio' && EMBED_AUDIO_THUMBNAIL && downloadConfig.embed_thumbnail === null))) {
embedThumbnail = 1;
args.push('--embed-thumbnail');
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) {
args.push('--proxy', PROXY_URL);
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_RATE_LIMIT && RATE_LIMIT) {
args.push('--limit-rate', `${RATE_LIMIT}`);
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) {
if (FORCE_INTERNET_PROTOCOL === 'ipv4') {
args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
args.push('--force-ipv6');
}
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
args.push('--cookies', COOKIES_FILE);
}
}
let sponsorblockRemove = null;
let sponsorblockMark = null;
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark))) {
if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) {
sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? (
SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default'
) : (SPONSORBLOCK_REMOVE));
args.push('--sponsorblock-remove', sponsorblockRemove);
} else if (SPONSORBLOCK_MODE === 'mark' || resumeState?.sponsorblock_mark) {
sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? (
SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default'
) : (SPONSORBLOCK_MARK));
args.push('--sponsorblock-mark', sponsorblockMark);
}
}
let useAria2 = 0;
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_ARIA2 || resumeState?.use_aria2)) {
useAria2 = 1;
args.push(
'--downloader', 'aria2c',
'--downloader', 'dash,m3u8:native',
'--downloader-args', 'aria2c:-c -j 16 -x 16 -s 16 -k 1M --check-certificate=false'
);
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 (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) {
args.push('--continue');
} else {
args.push('--no-continue');
}
if (USE_PROXY && PROXY_URL) {
args.push('--proxy', PROXY_URL);
}
console.log('Starting download with args:', args);
const command = Command.sidecar('binaries/yt-dlp', args);
command.on('close', async data => {
command.on('close', async (data) => {
if (data.code !== 0) {
console.error(`Download failed with code ${data.code}`);
LOG.error(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code} (ignore if you manually paused or cancelled the download)`);
if (!isErrorExpected) {
setIsErrored(true);
setErroredDownloadId(downloadId);
}
} else {
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download status:", error);
}
})
if (await fs.exists(tempDownloadPath)) {
downloadFilePath = await generateSafeFilePath(downloadFilePath);
await fs.rename(tempDownloadPath, downloadFilePath);
}
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath }, {
onSuccess: (data) => {
console.log("Download filepath updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download filepath:", error);
}
})
LOG.info(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code}`);
}
});
command.on('error', error => {
console.error(`Error: ${error}`);
LOG.error(`YT-DLP Download ${downloadId}`, `Error occurred: ${error}`);
setIsErrored(true);
setErroredDownloadId(downloadId);
});
command.stdout.on('data', line => {
if (line.startsWith('status:')) {
if (line.startsWith('status:') || line.startsWith('[#')) {
console.log(line);
LOG.info(`YT-DLP Download ${downloadId}`, line);
const currentProgress = parseProgressLine(line);
const state: DownloadState = {
download_id: downloadId,
@@ -262,7 +497,15 @@ export default function App({ children }: { children: React.ReactNode }) {
eta: currentProgress.eta || null,
filepath: downloadFilePath,
filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null,
filesize: videoMetadata.filesize_approx || null
filesize: videoMetadata.filesize_approx || null,
output_format: outputFormat,
embed_metadata: embedMetadata,
embed_thumbnail: embedThumbnail,
sponsorblock_remove: sponsorblockRemove,
sponsorblock_mark: sponsorblockMark,
use_aria2: useAria2,
custom_command: customCommandArgs,
queue_config: null
};
downloadStateSaver.mutate(state, {
onSuccess: (data) => {
@@ -275,6 +518,37 @@ export default function App({ children }: { children: React.ReactNode }) {
})
} else {
console.log(line);
if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, line);
if (line.startsWith('Finalpath: ')) {
downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
const downloadedFileExt = downloadFilePath.split('.').pop();
// Update completion status after a short delay to ensure database states are propagated correctly
console.log(`Download completed with ID: ${downloadId}, updating filepath and status after 2s delay...`);
setTimeout(() => {
LOG.info('NEODLP', `yt-dlp download completed with id: ${downloadId}`);
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath as string, ext: downloadedFileExt as string }, {
onSuccess: (data) => {
console.log("Download filepath updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download filepath:", error);
}
});
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download status:", error);
}
});
}, 2000);
}
}
});
@@ -347,7 +621,15 @@ export default function App({ children }: { children: React.ReactNode }) {
eta: resumeState?.eta || null,
filepath: downloadFilePath,
filetype: resumeState?.filetype || null,
filesize: resumeState?.filesize || null
filesize: resumeState?.filesize || null,
output_format: resumeState?.output_format || null,
embed_metadata: resumeState?.embed_metadata || 0,
embed_thumbnail: resumeState?.embed_thumbnail || 0,
sponsorblock_remove: resumeState?.sponsorblock_remove || null,
sponsorblock_mark: resumeState?.sponsorblock_mark || null,
use_aria2: resumeState?.use_aria2 || 0,
custom_command: resumeState?.custom_command || null,
queue_config: resumeState?.queue_config || ((!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : JSON.stringify(downloadConfig))
}
downloadStateSaver.mutate(state, {
onSuccess: (data) => {
@@ -365,27 +647,53 @@ export default function App({ children }: { children: React.ReactNode }) {
})
if (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) {
LOG.info('NEODLP', `Starting yt-dlp download with args: ${args.join(' ')}`);
const child = await command.spawn();
processPid = child.pid;
return Promise.resolve();
} else {
console.log("Download is queued, not starting immediately.");
LOG.info('NEODLP', `Download queued with id: ${downloadId}`);
return Promise.resolve();
}
} catch (e) {
console.error(`Failed to start download: ${e}`);
LOG.error('NEODLP', `Failed to start download for URL: ${url} with error: ${e}`);
throw e;
}
};
const pauseDownload = async (downloadState: DownloadState) => {
try {
LOG.info('NEODLP', `Pausing yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) {
setIsErrorExpected(true); // Set error expected to true to handle UI state
console.log("Killing process with PID:", downloadState.process_id);
await invoke('kill_all_process', { pid: downloadState.process_id });
}
downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
/* re-check if the download is properly paused (if not try again after a small delay)
as the pause opertion happens within high throughput of operations and have a high chgance of failure.
*/
if (isSuccessFetchingDownloadStates && downloadStates.find(state => state.download_id === downloadState.download_id)?.download_status !== 'paused') {
console.log("Download status not updated to paused yet, retrying...");
setTimeout(() => {
downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, {
onSuccess: (data) => {
console.log("Download status updated successfully on retry:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download status:", error);
}
});
}, 200);
}
// Reset the processing flag to ensure queue can be processed
isProcessingQueueRef.current = false;
@@ -401,6 +709,7 @@ export default function App({ children }: { children: React.ReactNode }) {
return Promise.resolve();
} catch (e) {
console.error(`Failed to pause download: ${e}`);
LOG.error('NEODLP', `Failed to pause download with id: ${downloadState.download_id} with error: ${e}`);
isProcessingQueueRef.current = false;
throw e;
}
@@ -408,22 +717,32 @@ export default function App({ children }: { children: React.ReactNode }) {
const resumeDownload = async (downloadState: DownloadState) => {
try {
LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
await startDownload(
downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url,
downloadState.format_id,
downloadState.queue_config ? JSON.parse(downloadState.queue_config) : {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
downloadState.subtitle_id,
downloadState
);
return Promise.resolve();
} catch (e) {
console.error(`Failed to resume download: ${e}`);
LOG.error('NEODLP', `Failed to resume download with id: ${downloadState.download_id} with error: ${e}`);
throw e;
}
};
const cancelDownload = async (downloadState: DownloadState) => {
try {
LOG.info('NEODLP', `Cancelling yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) {
setIsErrorExpected(true); // Set error expected to true to handle UI state
console.log("Killing process with PID:", downloadState.process_id);
await invoke('kill_all_process', { pid: downloadState.process_id });
}
@@ -447,6 +766,7 @@ export default function App({ children }: { children: React.ReactNode }) {
return Promise.resolve();
} catch (e) {
console.error(`Failed to cancel download: ${e}`);
LOG.error('NEODLP', `Failed to cancel download with id: ${downloadState.download_id} with error: ${e}`);
throw e;
}
}
@@ -487,6 +807,7 @@ export default function App({ children }: { children: React.ReactNode }) {
}
console.log("Starting queued download:", downloadToStart.download_id);
LOG.info('NEODLP', `Starting queued download with id: ${downloadToStart.download_id}`);
lastProcessedDownloadIdRef.current = downloadToStart.download_id;
// Update status to 'starting' first
@@ -502,12 +823,19 @@ export default function App({ children }: { children: React.ReactNode }) {
await startDownload(
downloadToStart.url,
downloadToStart.format_id,
downloadToStart.queue_config ? JSON.parse(downloadToStart.queue_config) : {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
downloadToStart.subtitle_id,
downloadToStart
);
} catch (error) {
console.error("Error processing download queue:", error);
LOG.error('NEODLP', `Error processing download queue: ${error}`);
} finally {
// Important: reset the processing flag
setTimeout(() => {
@@ -550,6 +878,7 @@ export default function App({ children }: { children: React.ReactNode }) {
appWindow.setFocus();
navigate('/');
if (event.payload.url) {
LOG.info('NEODLP', `Received download request from neodlp browser extension for URL: ${event.payload.url}`);
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
setRequestedUrl(event.payload.url);
setAutoSubmitSearch(true);
@@ -701,6 +1030,7 @@ export default function App({ children }: { children: React.ReactNode }) {
const YTDLP_UPDATE_INTERVAL = 86400000 // 24H;
if (YTDLP_AUTO_UPDATE && (ytDlpUpdateLastCheck === null || currentTimestamp - ytDlpUpdateLastCheck > YTDLP_UPDATE_INTERVAL)) {
console.log("Running auto-update for yt-dlp...");
LOG.info('NEODLP', 'Updating yt-dlp to latest version (triggered because auto-update is enabled)');
updateYtDlp();
} else {
console.log("Skipping yt-dlp auto-update, either disabled or recently updated.");
@@ -727,14 +1057,18 @@ export default function App({ children }: { children: React.ReactNode }) {
const currentPlatform = platform();
if (currentPlatform === 'macos' && (!macOsRegisteredVersion || macOsRegisteredVersion !== appVersion)) {
console.log("Running MacOS auto registration...");
LOG.info('NEODLP', 'Running macOS registration');
registerToMac().then((result: { success: boolean, message: string }) => {
if (result.success) {
console.log("MacOS registration successful:", result.message);
LOG.info('NEODLP', 'macOS registration successful');
} else {
console.error("MacOS registration failed:", result.message);
LOG.error('NEODLP', `macOS registration failed: ${result.message}`);
}
}).catch((error) => {
console.error("Error during macOS registration:", error);
LOG.error('NEODLP', `Error during macOS registration: ${error}`);
});
}
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
@@ -757,12 +1091,47 @@ export default function App({ children }: { children: React.ReactNode }) {
return () => clearTimeout(timeoutId);
}, [processQueuedDownloads, ongoingDownloads, queuedDownloads]);
// show a toast and pause the download when yt-dlp exits unexpectedly
useEffect(() => {
if (isErrored && !isErrorExpected) {
toast.error("Download Failed", {
description: "yt-dlp exited unexpectedly. Please try again later",
});
if (erroredDownloadId) {
downloadStatusUpdater.mutate({ download_id: erroredDownloadId, download_status: 'paused' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download status:", error);
}
})
setErroredDownloadId(null);
}
setIsErrored(false);
setIsErrorExpected(false);
}
}, [isErrored, isErrorExpected, erroredDownloadId, setIsErrored, setIsErrorExpected, setErroredDownloadId]);
// auto reset error states after 3 seconds of expecting an error
useEffect(() => {
if (isErrorExpected) {
const timeoutId = setTimeout(() => {
setIsErrored(false);
setIsErrorExpected(false);
setErroredDownloadId(null);
}, 3000);
return () => clearTimeout(timeoutId);
}
}, [isErrorExpected, setIsErrorExpected]);
return (
<AppContext.Provider value={{ fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload }}>
<ThemeProvider defaultTheme={APP_THEME || "system"} storageKey="vite-ui-theme">
<TooltipProvider delayDuration={1000}>
{children}
<Toaster />
<Sonner closeButton />
</TooltipProvider>
</ThemeProvider>
</AppContext.Provider>

View File

@@ -1,12 +1,15 @@
import { useLocation } from "react-router-dom";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getRouteName } from "@/utils";
// import { Button } from "@/components/ui/button";
// import { Terminal } from "lucide-react";
// import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Terminal } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useLogger } from "@/helpers/use-logger";
export default function Navbar() {
const location = useLocation();
const logs = useLogger().getLogs();
return (
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b z-50">
@@ -15,16 +18,38 @@ export default function Navbar() {
<h1 className="text-lg text-primary font-semibold ml-4">{getRouteName(location.pathname)}</h1>
</div>
<div className="flex justify-center items-center">
{/* <Tooltip>
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Terminal />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Logs</p>
</TooltipContent>
</Tooltip> */}
</Tooltip>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Log Viewer</DialogTitle>
<DialogDescription>Monitor real-time app session logs (latest on top)</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 p-2 max-h-[300px] overflow-y-scroll overflow-x-hidden bg-muted">
{logs.length === 0 ? (
<p className="text-sm text-muted-foreground">NO LOGS TO SHOW!</p>
) : (
logs.slice().reverse().map((log, index) => (
<div key={index} className={`flex flex-col ${log.level === 'error' ? 'text-red-500' : log.level === 'warning' ? 'text-amber-500' : log.level === 'debug' ? 'text-sky-500' : 'text-foreground'}`}>
<p className="text-xs"><strong>{new Date(log.timestamp).toLocaleTimeString()}</strong> [{log.level.toUpperCase()}] <em>{log.context}</em> :</p>
<p className="text-xs font-mono break-all">{log.message}</p>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
</nav>
)

View File

@@ -1,7 +1,7 @@
import { config } from "@/config";
import { Link, useLocation } from "react-router-dom";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
import { CircleArrowUp, Download, Puzzle, Settings, SquarePlay, } from "lucide-react";
import { CircleArrowUp, Download, Settings, SquarePlay, } from "lucide-react";
import { isActive as isActiveSidebarItem } from "@/utils";
import { RoutesObj } from "@/types/route";
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
@@ -29,6 +29,7 @@ export function AppSidebar() {
const { open } = useSidebar();
const { downloadAndInstallAppUpdate } = useAppUpdater();
const [showBadge, setShowBadge] = useState(false);
const [showUpdateCard, setShowUpdateCard] = useState(false);
const topItems: Array<RoutesObj> = [
{
@@ -40,11 +41,6 @@ export function AppSidebar() {
title: "Library",
url: "/library",
icon: SquarePlay,
},
{
title: "Extension",
url: "/extension",
icon: Puzzle,
}
];
@@ -61,9 +57,11 @@ export function AppSidebar() {
if (open) {
timeout = setTimeout(() => {
setShowBadge(true);
setShowUpdateCard(true);
}, 300);
} else {
setShowBadge(false);
setShowUpdateCard(false);
}
return () => {
@@ -153,13 +151,14 @@ export function AppSidebar() {
</TooltipContent>
</Tooltip>
)}
{appUpdate && open && (
<Card>
{appUpdate && open && showUpdateCard && (
<Card className="gap-4 py-0">
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm">Update Available (v{appUpdate.version})</CardTitle>
<CardTitle className="text-sm">Update Available (v{appUpdate?.version || '0.0.0'})</CardTitle>
<CardDescription>
A new version of {config.appName} is available. Please update to the latest version for the best experience.
A newer version of {config.appName} is available. Please update to the latest version for the best experience.
</CardDescription>
<a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/neosubhamoy/neodlp/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank"> Read Changelog</a>
</CardHeader>
<CardContent className="grid gap-2.5 p-4">
<AlertDialog>
@@ -170,13 +169,14 @@ export function AppSidebar() {
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
onClick={() => downloadAndInstallAppUpdate(appUpdate)}
>
Download and Install
Update Now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
<CircleArrowUp className="size-7 stroke-muted-foreground" />
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
<AlertDialogDescription className="text-center">Updating {config.appName} to v{appUpdate.version}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
<AlertDialogDescription className="text-center text-xs mb-2">Updating {config.appName} to v{appUpdate?.version || '0.0.0'}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
<Progress value={appUpdateDownloadProgress} className="w-full" />
<AlertDialogDescription className="text-center">Downloading update... {appUpdateDownloadProgress}%</AlertDialogDescription>
</AlertDialogHeader>

View File

@@ -1,5 +1,13 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import type { Props as LegendProps } from "recharts/types/component/Legend"
import { TooltipContentProps } from "recharts/types/component/Tooltip"
import { cn } from "@/lib/utils"
@@ -20,6 +28,36 @@ type ChartContextProps = {
config: ChartConfig
}
export type CustomTooltipProps = TooltipContentProps<ValueType, NameType> & {
className?: string
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (
label: TooltipContentProps<number, string>["label"],
payload: TooltipContentProps<number, string>["payload"]
) => React.ReactNode
formatter?: (
value: number | string,
name: string,
item: Payload<number | string, string>,
index: number,
payload: ReadonlyArray<Payload<number | string, string>>
) => React.ReactNode
labelClassName?: string
color?: string
}
export type ChartLegendContentProps = {
className?: string
hideIcon?: boolean
verticalAlign?: LegendProps["verticalAlign"]
payload?: LegendPayload[]
nameKey?: string
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
@@ -105,25 +143,18 @@ const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
label,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
labelClassName,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
}: CustomTooltipProps) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
@@ -134,11 +165,15 @@ function ChartTooltipContent({
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
const value = (() => {
const v =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
? config[label as keyof typeof config]?.label ?? label
: itemConfig?.label
return typeof v === "string" || typeof v === "number" ? v : undefined
})()
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
@@ -254,11 +289,7 @@ function ChartLegendContent({
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
}: ChartLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {

View File

@@ -15,6 +15,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-border": "var(--border)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "group",
icon: "group-data-[type=error]:!text-red-500 group-data-[type=success]:!text-green-500 group-data-[type=warning]:!text-amber-500 group-data-[type=info]:!text-sky-500",
},
}}
{...props}
/>
)

View File

@@ -31,15 +31,16 @@ export default function useAppUpdater() {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength;
console.log(`started downloading ${event.data.contentLength} bytes`);
console.log(`started downloading app update of ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloadProgress(downloaded / (contentLength || 0));
console.log(`downloaded ${downloaded} from ${contentLength}`);
const progress = (downloaded / (contentLength || 1)) * 100;
setDownloadProgress(Math.round(progress * 10) / 10);
console.log(`downloaded ${downloaded} bytes from ${contentLength} bytes of app update`);
break;
case 'Finished':
console.log('download finished');
console.log('app update download finished');
setIsUpdating(false);
break;
}

26
src/helpers/use-logger.ts Normal file
View File

@@ -0,0 +1,26 @@
import { useLogsStore } from "@/services/store";
export function useLogger() {
const logs = useLogsStore((state) => state.logs);
const addLog = useLogsStore((state) => state.addLog);
const clearLogs = useLogsStore((state) => state.clearLogs);
const logger = {
info: (context: string, message: string) => {
addLog({ timestamp: Date.now(), level: 'info', context, message });
},
warning: (context: string, message: string) => {
addLog({ timestamp: Date.now(), level: 'warning', context, message });
},
error: (context: string, message: string) => {
addLog({ timestamp: Date.now(), level: 'error', context, message });
},
debug: (context: string, message: string) => {
addLog({ timestamp: Date.now(), level: 'debug', context, message });
},
getLogs: () => logs,
clearLogs,
};
return logger;
}

View File

@@ -1,11 +1,10 @@
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useResetSettings, useSaveSettingsKey } from "@/services/mutations";
import { useSettingsPageStatesStore } from "@/services/store";
import { useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
export function useSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const setSettingsKey = useSettingsPageStatesStore(state => state.setSettingsKey);
const resetSettingsState = useSettingsPageStatesStore(state => state.resetSettings);
@@ -22,10 +21,8 @@ export function useSettings() {
onError: (error) => {
console.error("Error saving settings key:", error);
queryClient.invalidateQueries({ queryKey: ["settings"] });
toast({
title: "Failed to update settings",
toast.error("Failed to update settings", {
description: `Failed to update ${key}`,
variant: "destructive",
});
}
});
@@ -39,26 +36,21 @@ export function useSettings() {
resetSettingsState();
console.log("Settings reset successfully");
queryClient.invalidateQueries({ queryKey: ["settings"] });
toast({
title: "Settings reset successfully",
toast.success("Settings reset successfully", {
description: "All settings have been reset to default.",
});
} catch (error) {
console.error("Error resetting settings:", error);
toast({
title: "Failed to reset settings",
toast.error("Failed to reset settings", {
description: "Failed to reset settings to default.",
variant: "destructive",
});
return;
}
},
onError: (error) => {
console.error("Error resetting settings:", error);
toast({
title: "Failed to reset settings",
toast.error("Failed to reset settings", {
description: "Failed to reset settings to default.",
variant: "destructive",
});
}
});

View File

@@ -8,7 +8,6 @@ import RootLayout from "@/pages/layout/root";
import DownloaderPage from "@/pages/downloader";
import LibraryPage from "@/pages/library";
import SettingsPage from "@/pages/settings";
import ExtensionPage from "@/pages/extension";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
@@ -19,7 +18,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="/" element={<RootLayout />}>
<Route index element={<DownloaderPage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/extension" element={<ExtensionPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>

View File

@@ -5,11 +5,11 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore } from "@/services/store";
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { determineFileType, fileFormatFilter, formatBitrate, formatDurationString, formatFileSize, formatReleaseDate, formatYtStyleCount, isObjEmpty, sortByBitrate } from "@/utils";
import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch } from "lucide-react";
import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch, AlertCircleIcon, X, Settings2 } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { useEffect, useRef } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup";
@@ -21,35 +21,67 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { config } from "@/config";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { invoke } from "@tauri-apps/api/core";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
const searchFormSchema = z.object({
url: z.string().min(1, { message: "URL is required" })
.url({message: "Invalid URL format." }),
url: z.url({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "URL is required"
: "Invalid URL format"
}),
});
export default function DownloaderPage() {
const { fetchVideoMetadata, startDownload } = useAppContext();
const { toast } = useToast();
const videoUrl = useCurrentVideoMetadataStore((state) => state.videoUrl);
const videoMetadata = useCurrentVideoMetadataStore((state) => state.videoMetadata);
const isMetadataLoading = useCurrentVideoMetadataStore((state) => state.isMetadataLoading);
const requestedUrl = useCurrentVideoMetadataStore((state) => state.requestedUrl);
const autoSubmitSearch = useCurrentVideoMetadataStore((state) => state.autoSubmitSearch);
const searchPid = useCurrentVideoMetadataStore((state) => state.searchPid);
const setVideoUrl = useCurrentVideoMetadataStore((state) => state.setVideoUrl);
const setVideoMetadata = useCurrentVideoMetadataStore((state) => state.setVideoMetadata);
const setIsMetadataLoading = useCurrentVideoMetadataStore((state) => state.setIsMetadataLoading);
const setRequestedUrl = useCurrentVideoMetadataStore((state) => state.setRequestedUrl);
const setAutoSubmitSearch = useCurrentVideoMetadataStore((state) => state.setAutoSubmitSearch);
const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid);
const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError);
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab);
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
const selctedDownloadFormat = useDownloaderPageStatesStore((state) => state.selctedDownloadFormat);
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
const setSelctedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelctedDownloadFormat);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
const audioOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('audio'))) : [];
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
@@ -79,22 +111,44 @@ export default function DownloaderPage() {
const allFilteredFormats = [...(audioOnlyFormats || []), ...(videoOnlyFormats || []), ...(combinedFormats || []), ...(qualityPresetFormats || [])];
const selectedFormat = (() => {
if (videoMetadata?._type === 'video') {
if (selctedDownloadFormat === 'best') {
if (selectedDownloadFormat === 'best') {
return videoMetadata?.requested_downloads[0];
}
return allFilteredFormats.find(
(format) => format.format_id === selctedDownloadFormat
(format) => format.format_id === selectedDownloadFormat
);
} else if (videoMetadata?._type === 'playlist') {
if (selctedDownloadFormat === 'best') {
if (selectedDownloadFormat === 'best') {
return videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0];
}
return allFilteredFormats.find(
(format) => format.format_id === selctedDownloadFormat
(format) => format.format_id === selectedDownloadFormat
);
}
})();
const selectedFormatFileType = determineFileType(selectedFormat?.vcodec, selectedFormat?.acodec);
const selectedVideoFormat = (() => {
if (videoMetadata?._type === 'video') {
return allFilteredFormats.find(
(format) => format.format_id === selectedCombinableVideoFormat
);
} else if (videoMetadata?._type === 'playlist') {
return allFilteredFormats.find(
(format) => format.format_id === selectedCombinableVideoFormat
);
}
})();
const selectedAudioFormat = (() => {
if (videoMetadata?._type === 'video') {
return allFilteredFormats.find(
(format) => format.format_id === selectedCombinableAudioFormat
);
} else if (videoMetadata?._type === 'playlist') {
return allFilteredFormats.find(
(format) => format.format_id === selectedCombinableAudioFormat
);
}
})();
const subtitles = videoMetadata?._type === 'video' ? (videoMetadata?.subtitles || {}) : videoMetadata?._type === 'playlist' ? (videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].subtitles || {}) : {};
const subtitleLanguages = Object.keys(subtitles).map(langCode => ({
@@ -105,6 +159,67 @@ export default function DownloaderPage() {
const containerRef = useRef<HTMLDivElement>(null);
const bottomBarRef = useRef<HTMLDivElement>(null);
let selectedFormatExtensionMsg = 'Auto - unknown';
if (activeDownloadModeTab === 'combine') {
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${downloadConfiguration.output_format.toUpperCase()}`;
}
else if (videoFormat !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`;
}
else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) {
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormat.ext.toUpperCase()}`;
} else {
selectedFormatExtensionMsg = `Combined - unknown`;
}
} else if (selectedFormat?.ext) {
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : audioFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'unknown' && downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Forced - ${downloadConfiguration.output_format.toUpperCase()}`;
} else {
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
}
}
let selectedFormatResolutionMsg = 'unknown';
if (activeDownloadModeTab === 'combine') {
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${selectedAudioFormat?.tbr ? formatBitrate(selectedAudioFormat.tbr) : 'unknown'}`;
} else if (selectedFormat?.resolution) {
selectedFormatResolutionMsg = selectedFormat.resolution;
}
let selectedFormatDynamicRangeMsg = '';
if (activeDownloadModeTab === 'combine') {
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : '';
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') {
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
}
let selectedFormatFileSizeMsg = 'unknown filesize';
if (activeDownloadModeTab === 'combine') {
selectedFormatFileSizeMsg = selectedVideoFormat?.filesize_approx && selectedAudioFormat?.filesize_approx ? formatFileSize(selectedVideoFormat.filesize_approx + selectedAudioFormat.filesize_approx) : 'unknown filesize';
} else if (selectedFormat?.filesize_approx) {
selectedFormatFileSizeMsg = formatFileSize(selectedFormat.filesize_approx);
}
let selectedFormatFinalMsg = '';
if (activeDownloadModeTab === 'combine') {
if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''}${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Choose a video and audio stream to combine`;
}
} else {
if (selectedFormat) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''}${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Choose a stream to download`;
}
}
const searchForm = useForm<z.infer<typeof searchFormSchema>>({
resolver: zodResolver(searchFormSchema),
defaultValues: {
@@ -113,27 +228,45 @@ export default function DownloaderPage() {
mode: "onChange",
})
const watchedUrl = searchForm.watch("url");
const { errors: searchFormErrors } = searchForm.formState;
function handleSearchSubmit(values: z.infer<typeof searchFormSchema>) {
setVideoMetadata(null);
setSearchPid(null);
setShowSearchError(true);
setIsMetadataLoading(true);
setSelctedDownloadFormat('best');
setSelectedDownloadFormat('best');
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
setSelectedSubtitles([]);
setSelectedPlaylistVideoIndex('1');
resetDownloadConfiguration();
fetchVideoMetadata(values.url).then((metadata) => {
if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) {
toast({
title: 'Opps! No results found',
description: 'The provided URL does not contain any downloadable content. Please check the URL and try again.',
variant: "destructive"
const showSearchError = useCurrentVideoMetadataStore.getState().showSearchError;
if (showSearchError) {
toast.error("Oops! No results found", {
description: "The provided URL does not contain any downloadable content or you are not connected to the internet. Please check the URL, your network connection and try again.",
});
}
}
if (metadata && (metadata._type === 'video' || metadata._type === 'playlist') && ((metadata._type === 'video' && metadata.formats.length > 0) || (metadata._type === 'playlist' && metadata.entries.length > 0))) setVideoMetadata(metadata);
if (metadata) console.log(metadata);
setIsMetadataLoading(false);
});
}
const cancelSearch = async (pid: number | null) => {
setShowSearchError(false);
if (pid) {
console.log("Killing process with PID:", pid);
await invoke('kill_all_process', { pid: pid });
}
setVideoMetadata(null);
setIsMetadataLoading(false);
};
useEffect(() => {
const updateBottomBarWidth = (): void => {
if (containerRef.current && bottomBarRef.current) {
@@ -158,6 +291,10 @@ export default function DownloaderPage() {
};
}, []);
useEffect(() => {
useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options');
}, []);
useEffect(() => {
if (watchedUrl !== videoUrl) {
setVideoUrl(watchedUrl);
@@ -192,20 +329,16 @@ export default function DownloaderPage() {
// If URL is invalid, just reset the flag
setAutoSubmitSearch(false);
setRequestedUrl('');
toast({
title: 'Invalid URL',
description: 'The provided URL is not valid.',
variant: "destructive"
toast.error("Invalid URL", {
description: "The provided URL is not valid.",
});
}
} else {
// If metadata is loading, just reset the flag
setAutoSubmitSearch(false);
setRequestedUrl('');
toast({
title: 'Search in progress',
description: 'Search in progress, try again later.',
variant: "destructive"
toast.info("Search in progress", {
description: "There's a search in progress, Please try again later.",
});
}
} else {
@@ -247,9 +380,20 @@ export default function DownloaderPage() {
</FormItem>
)}
/>
{isMetadataLoading && (
<Button
type="button"
variant="destructive"
size="icon"
disabled={!isMetadataLoading}
onClick={() => cancelSearch(searchPid)}
>
<X className="size-4" />
</Button>
)}
<Button
type="submit"
disabled={!videoUrl || isMetadataLoading}
disabled={!videoUrl || Object.keys(searchFormErrors).length > 0 || isMetadataLoading}
>
{isMetadataLoading ? (
<>
@@ -267,11 +411,11 @@ export default function DownloaderPage() {
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'video' && ( // === Single Video ===
<div className="flex">
<div className="flex flex-col w-[55%] border-r border-border pr-4">
<h3 className="text-sm mb-4 flex items-center gap-2">
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
<Info className="w-4 h-4" />
<span>Metadata</span>
</h3>
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
<AspectRatio ratio={16 / 9} className={clsx("w-full rounded-lg overflow-hidden mb-2 border border-border", videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "relative")}>
<ProxyImage src={videoMetadata.thumbnail} alt="thumbnail" className={clsx(videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2")} />
</AspectRatio>
@@ -309,15 +453,32 @@ export default function DownloaderPage() {
<Info className="w-3 h-3 mr-2" />
<span className="text-xs">Extracted from {videoMetadata.extractor ? videoMetadata.extractor.charAt(0).toUpperCase() + videoMetadata.extractor.slice(1) : 'Unknown'}</span>
</div>
<div className="spacer mb-14"></div>
<div className="spacer mb-10"></div>
</div>
</div>
<div className="flex flex-col w-full pl-4">
<h3 className="text-sm mb-4 flex items-center gap-2">
<Tabs
className=""
value={activeDownloadModeTab}
onValueChange={(tab) => {
setActiveDownloadModeTab(tab)
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm flex items-center gap-2">
<DownloadCloud className="w-4 h-4" />
<span>Download Options</span>
</h3>
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
<TabsList>
<TabsTrigger value="selective">Selective</TabsTrigger>
<TabsTrigger value="combine">Combine</TabsTrigger>
</TabsList>
</div>
<TabsContent value="selective">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
{subtitles && !isObjEmpty(subtitles) && (
<ToggleGroup
type="multiple"
@@ -325,7 +486,7 @@ export default function DownloaderPage() {
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
@@ -343,13 +504,16 @@ export default function DownloaderPage() {
</ToggleGroup>
)}
<FormatSelectionGroup
value={selctedDownloadFormat}
value={selectedDownloadFormat}
onValueChange={(value) => {
setSelctedDownloadFormat(value);
const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
setSelectedSubtitles([]);
}
setSelectedDownloadFormat(value);
// const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
// if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
// setSelectedSubtitles([]);
// }
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
<p className="text-xs">Suggested</p>
@@ -417,19 +581,107 @@ export default function DownloaderPage() {
</>
)}
</FormatSelectionGroup>
<div className="spacer mb-14"></div>
<div className="spacer mb-10"></div>
</div>
</TabsContent>
<TabsContent value="combine">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitles && !isObjEmpty(subtitles) && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
)}
<FormatSelectionGroup
className="mb-2"
value={selectedCombinableAudioFormat}
onValueChange={(value) => {
setSelectedCombinableAudioFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
<FormatSelectionGroup
value={selectedCombinableVideoFormat}
onValueChange={(value) => {
setSelectedCombinableVideoFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
<Alert>
<AlertCircleIcon />
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
<AlertDescription>
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
</AlertDescription>
</Alert>
)}
<div className="spacer mb-10"></div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
)}
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'playlist' && ( // === Playlists ===
<div className="flex">
<div className="flex flex-col w-[55%] border-r border-border pr-4">
<h3 className="text-sm mb-4 flex items-center gap-2">
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
<ListVideo className="w-4 h-4" />
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
</h3>
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
{/* <PlaylistToggleGroup
@@ -451,8 +703,11 @@ export default function DownloaderPage() {
value={selectedPlaylistVideoIndex}
onValueChange={(value) => {
setSelectedPlaylistVideoIndex(value);
setSelctedDownloadFormat('best');
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}}
>
{videoMetadata.entries.map((entry) => entry ? (
@@ -467,15 +722,27 @@ export default function DownloaderPage() {
<Info className="w-3 h-3 mr-2" />
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
</div>
<div className="spacer mb-14"></div>
<div className="spacer mb-10"></div>
</div>
</div>
<div className="flex flex-col w-full pl-4">
<h3 className="text-sm mb-4 flex items-center gap-2">
<Tabs
className=""
value={activeDownloadModeTab}
onValueChange={(tab) => setActiveDownloadModeTab(tab)}
>
<div className="flex items-center justify-between">
<h3 className="text-sm flex items-center gap-2">
<DownloadCloud className="w-4 h-4" />
<span>Download Options</span>
</h3>
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
<TabsList>
<TabsTrigger value="selective">Selective</TabsTrigger>
<TabsTrigger value="combine">Combine</TabsTrigger>
</TabsList>
</div>
<TabsContent value="selective">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
{subtitles && !isObjEmpty(subtitles) && (
<ToggleGroup
type="multiple"
@@ -501,9 +768,9 @@ export default function DownloaderPage() {
</ToggleGroup>
)}
<FormatSelectionGroup
value={selctedDownloadFormat}
value={selectedDownloadFormat}
onValueChange={(value) => {
setSelctedDownloadFormat(value);
setSelectedDownloadFormat(value);
const currentlySelectedFormat = value === 'best' ? videoMetadata?.entries[Number(value) - 1].requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
setSelectedSubtitles([]);
@@ -575,12 +842,95 @@ export default function DownloaderPage() {
</>
)}
</FormatSelectionGroup>
<div className="spacer mb-14"></div>
<div className="spacer mb-10"></div>
</div>
</TabsContent>
<TabsContent value="combine">
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitles && !isObjEmpty(subtitles) && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
)}
<FormatSelectionGroup
className="mb-2"
value={selectedCombinableAudioFormat}
onValueChange={(value) => {
setSelectedCombinableAudioFormat(value);
}}
>
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
<FormatSelectionGroup
value={selectedCombinableVideoFormat}
onValueChange={(value) => {
setSelectedCombinableVideoFormat(value);
}}
>
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
<Alert>
<AlertCircleIcon />
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
<AlertDescription>
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
</AlertDescription>
</Alert>
)}
<div className="spacer mb-10"></div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
)}
{!isMetadataLoading && videoMetadata && selctedDownloadFormat && ( // === Bottom Bar ===
{!isMetadataLoading && videoMetadata && selectedDownloadFormat && ( // === Bottom Bar ===
<div className="flex justify-between items-center gap-2 fixed bottom-0 right-0 p-4 w-full bg-background rounded-t-lg border-t border-border z-20" ref={bottomBarRef}>
<div className="flex items-center gap-4">
<div className="flex justify-center items-center p-3 rounded-md border border-border">
@@ -596,9 +946,201 @@ export default function DownloaderPage() {
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-nowrap max-w-[30rem] xl:max-w-[50rem] overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' }</span>
<span className="text-xs text-muted-foreground">{selectedFormat?.ext ? selectedFormat.ext.toUpperCase() : 'unknown'} ({selectedFormat?.resolution ? selectedFormat.resolution : 'unknown'}) {selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' ? selectedFormat.dynamic_range : null } {selectedSubtitles.length > 0 ? `• ESUB` : null} {selectedFormat?.filesize_approx ? formatFileSize(selectedFormat?.filesize_approx) : 'unknown filesize'}</span>
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={!selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat))}
>
<Settings2 className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
<DialogDescription>Tweak this download's configurations</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-scroll overflow-x-hidden no-scrollbar">
<Tabs
className=""
value={activeDownloadConfigurationTab}
onValueChange={(tab) => setActiveDownloadConfigurationTab(tab)}
>
<TabsList>
<TabsTrigger value="options">Options</TabsTrigger>
<TabsTrigger value="commands">Commands</TabsTrigger>
</TabsList>
<TabsContent value="options">
{useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon />
<AlertTitle className="text-sm">Options Unavailable!</AlertTitle>
<AlertDescription className="text-xs">
You cannot use these options when custom commands are enabled. To use these options, disable custom commands from Settings.
</AlertDescription>
</Alert>
) : null}
<div className="video-format">
<Label className="text-xs mb-3 mt-2">Output Format ({(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? 'Video' : selectedFormatFileType && selectedFormatFileType === 'audio' ? 'Audio' : 'Unknown'})</Label>
{(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="v-auto" />
<Label htmlFor="v-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="v-mp4" />
<Label htmlFor="v-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="v-webm" />
<Label htmlFor="v-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="v-mkv" />
<Label htmlFor="v-mkv">MKV</Label>
</div>
</RadioGroup>
) : selectedFormatFileType && selectedFormatFileType === 'audio' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="a-auto" />
<Label htmlFor="a-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="a-m4a" />
<Label htmlFor="a-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="a-opus" />
<Label htmlFor="a-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="a-mp3" />
<Label htmlFor="a-mp3">MP3</Label>
</div>
</RadioGroup>
) : (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="u-auto" />
<Label htmlFor="u-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="u-mp4" />
<Label htmlFor="u-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="u-webm" />
<Label htmlFor="u-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="u-mkv" />
<Label htmlFor="u-mkv">MKV</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="u-m4a" />
<Label htmlFor="u-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="u-opus" />
<Label htmlFor="u-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="u-mp3" />
<Label htmlFor="u-mp3">MP3</Label>
</div>
</RadioGroup>
)}
</div>
<div className="embeding-options">
<Label className="text-xs my-3">Embeding Options</Label>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-metadata"
checked={downloadConfiguration.embed_metadata !== null ? downloadConfiguration.embed_metadata : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoMetadata : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioMetadata : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_metadata', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-metadata">Embed Metadata</Label>
</div>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-thumbnail"
checked={downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? false : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_thumbnail', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-thumbnail">Embed Thumbnail</Label>
</div>
</div>
</TabsContent>
<TabsContent value="commands">
{!useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon />
<AlertTitle className="text-sm">Enable Custom Commands!</AlertTitle>
<AlertDescription className="text-xs">
To run custom commands for downloads, please enable it from the Settings.
</AlertDescription>
</Alert>
) : null}
<div className="custom-commands">
<Label className="text-xs mb-3 mt-2">Run Custom Command</Label>
{customCommands.length === 0 ? (
<p className="text-sm text-muted-foreground">NO CUSTOM COMMAND TEMPLATE ADDED YET!</p>
) : (
<RadioGroup
orientation="vertical"
className="flex flex-col gap-2"
disabled={!useCustomCommands}
value={downloadConfiguration.custom_command}
onValueChange={(value) => setDownloadConfigurationKey('custom_command', value)}
>
{customCommands.map((command) => (
<div className="flex items-center gap-3" key={command.id}>
<RadioGroupItem value={command.id} id={`cmd-${command.id}`} />
<Label htmlFor={`cmd-${command.id}`}>{command.label}</Label>
</div>
))}
</RadioGroup>
)}
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
<Button
onClick={async () => {
setIsStartingDownload(true);
@@ -606,7 +1148,8 @@ export default function DownloaderPage() {
if (videoMetadata._type === 'playlist') {
await startDownload(
videoMetadata.original_url,
selctedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selctedDownloadFormat,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
undefined,
selectedPlaylistVideoIndex
@@ -614,7 +1157,8 @@ export default function DownloaderPage() {
} else if (videoMetadata._type === 'video') {
await startDownload(
videoMetadata.webpage_url,
selctedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selctedDownloadFormat,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
);
}
@@ -624,27 +1168,26 @@ export default function DownloaderPage() {
// });
} catch (error) {
console.error('Download failed to start:', error);
toast({
title: 'Failed to Start Download',
description: 'There was an error initiating the download.',
variant: "destructive"
toast.error("Failed to Start Download", {
description: "There was an error initiating the download."
});
} finally {
setIsStartingDownload(false);
}
}}
disabled={isStartingDownload}
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat)) || (useCustomCommands && !downloadConfiguration.custom_command)}
>
{isStartingDownload ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting
Starting Download
</>
) : (
'Start Download'
)}
</Button>
</div>
</div>
)}
</div>
);

View File

@@ -1,77 +0,0 @@
import { SlidingButton } from "@/components/custom/slidingButton";
import Heading from "@/components/heading";
import { ArrowRight } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
export default function ExtensionPage() {
const { toast } = useToast();
const openLink = async (url: string, app: string | null) => {
try {
await invoke('open_file_with_app', { filePath: url, appName: app }).then(() => {
toast({
title: 'Opening Link',
description: `Opening link with ${app ? app : 'default app'}.`,
})
});
} catch (e) {
console.error(e);
toast({
title: 'Failed to open link',
description: 'An error occurred while trying to open the link.',
variant: "destructive"
})
}
}
return (
<div className="container mx-auto p-4 space-y-4">
<Heading title="Extension" description="Integrate NeoDLP with your favourite browser" />
<div className="flex items-center gap-4">
<SlidingButton
slidingContent={
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
<ArrowRight className="size-4" />
<span>Get Now</span>
</div>
}
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'chrome')}
>
<span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M0 256C0 209.4 12.5 165.6 34.3 127.1L144.1 318.3C166 357.5 207.9 384 256 384C270.3 384 283.1 381.7 296.8 377.4L220.5 509.6C95.9 492.3 0 385.3 0 256zM365.1 321.6C377.4 302.4 384 279.1 384 256C384 217.8 367.2 183.5 340.7 160H493.4C505.4 189.6 512 222.1 512 256C512 397.4 397.4 511.1 256 512L365.1 321.6zM477.8 128H256C193.1 128 142.3 172.1 130.5 230.7L54.2 98.5C101 38.5 174 0 256 0C350.8 0 433.5 51.5 477.8 128V128zM168 256C168 207.4 207.4 168 256 168C304.6 168 344 207.4 344 256C344 304.6 304.6 344 256 344C207.4 344 168 304.6 168 256z"/>
</svg>
Get Chrome Extension
</span>
<span className="text-xs">from Chrome Web Store</span>
</SlidingButton>
<SlidingButton
slidingContent={
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
<ArrowRight className="size-4" />
<span>Get Now</span>
</div>
}
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'firefox')}
>
<span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M130.2 127.5C130.4 127.6 130.3 127.6 130.2 127.5V127.5zM481.6 172.9C471 147.4 449.6 119.9 432.7 111.2C446.4 138.1 454.4 165 457.4 185.2C457.4 185.3 457.4 185.4 457.5 185.6C429.9 116.8 383.1 89.1 344.9 28.7C329.9 5.1 334 3.5 331.8 4.1L331.7 4.2C285 30.1 256.4 82.5 249.1 126.9C232.5 127.8 216.2 131.9 201.2 139C199.8 139.6 198.7 140.7 198.1 142C197.4 143.4 197.2 144.9 197.5 146.3C197.7 147.2 198.1 148 198.6 148.6C199.1 149.3 199.8 149.9 200.5 150.3C201.3 150.7 202.1 151 203 151.1C203.8 151.1 204.7 151 205.5 150.8L206 150.6C221.5 143.3 238.4 139.4 255.5 139.2C318.4 138.7 352.7 183.3 363.2 201.5C350.2 192.4 326.8 183.3 304.3 187.2C392.1 231.1 368.5 381.8 247 376.4C187.5 373.8 149.9 325.5 146.4 285.6C146.4 285.6 157.7 243.7 227 243.7C234.5 243.7 256 222.8 256.4 216.7C256.3 214.7 213.8 197.8 197.3 181.5C188.4 172.8 184.2 168.6 180.5 165.5C178.5 163.8 176.4 162.2 174.2 160.7C168.6 141.2 168.4 120.6 173.5 101.1C148.5 112.5 129 130.5 114.8 146.4H114.7C105 134.2 105.7 93.8 106.3 85.3C106.1 84.8 99 89 98.1 89.7C89.5 95.7 81.6 102.6 74.3 110.1C58 126.7 30.1 160.2 18.8 211.3C14.2 231.7 12 255.7 12 263.6C12 398.3 121.2 507.5 255.9 507.5C376.6 507.5 478.9 420.3 496.4 304.9C507.9 228.2 481.6 173.8 481.6 172.9z"/>
</svg>
Get Firefox Extension
</span>
<span className="text-xs">from Mozilla Addons Store</span>
</SlidingButton>
</div>
<div className="flex gap-2">
<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', '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', 'arc')}>Arc</Button>
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'zen')}>Zen</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed befor clicking the link</p>
</div>
)
}

View File

@@ -4,11 +4,11 @@ import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useDownloadActionStatesStore, useDownloadStatesStore } from "@/services/store";
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useDownloadStatesStore, useLibraryPageStatesStore } from "@/services/store";
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
import { AudioLines, Clock, CloudDownload, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Loader2, Music, PackageCheck, Pause, Play, Trash2, Video, X } from "lucide-react";
import { AudioLines, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Loader2, Music, Pause, Play, Search, Square, Trash2, Video, X } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import * as fs from "@tauri-apps/plugin-fs";
import { DownloadState } from "@/types/download";
@@ -18,9 +18,15 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import Heading from "@/components/heading";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { useNavigate } from "react-router-dom";
import { useLogger } from "@/helpers/use-logger";
export default function LibraryPage() {
const activeTab = useLibraryPageStatesStore(state => state.activeTab);
const setActiveTab = useLibraryPageStatesStore(state => state.setActiveTab);
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload);
@@ -29,36 +35,41 @@ export default function LibraryPage() {
const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked);
const { pauseDownload, resumeDownload, cancelDownload } = useAppContext()
const { toast } = useToast();
const queryClient = useQueryClient();
const downloadStateDeleter = useDeleteDownloadState();
const navigate = useNavigate();
const LOG = useLogger();
const incompleteDownloads = downloadStates.filter(state => state.download_status !== 'completed');
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed');
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed')
.sort((a, b) => {
// Latest updated first
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return dateB - dateA;
});
const ongoingDownloads = downloadStates.filter(state =>
['starting', 'downloading', 'queued'].includes(state.download_status)
);
const openFile = async (filePath: string | null, app: string | null) => {
if (filePath && await fs.exists(filePath)) {
try {
await invoke('open_file_with_app', { filePath: filePath, appName: app }).then(() => {
toast({
title: 'Opening file',
description: `Opening the file with ${app ? app : 'default app'}.`,
toast.info(`${app === 'explorer' ? 'Revealing' : 'Opening'} file`, {
description: `${app === 'explorer' ? 'Revealing' : 'Opening'} the file ${app === 'explorer' ? 'in' : 'with'} ${app ? app : 'default app'}.`,
})
});
} catch (e) {
console.error(e);
toast({
title: 'Failed to open file',
description: 'An error occurred while trying to open the file.',
variant: "destructive"
toast.error(`Failed to ${app === 'explorer' ? 'reveal' : 'open'} file`, {
description: `An error occurred while trying to ${app === 'explorer' ? 'reveal' : 'open'} the file.`,
})
}
} else {
toast({
title: 'File unavailable',
description: 'The file you are trying to open does not exist.',
variant: "destructive"
toast.info("File unavailable", {
description: `The file you are trying to ${app === 'explorer' ? 'reveal' : 'open'} does not exist.`,
})
}
}
@@ -80,211 +91,102 @@ export default function LibraryPage() {
onSuccess: (data) => {
console.log("Download State deleted successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
toast({
title: 'Removed from downloads',
description: 'The download has been removed successfully.',
})
toast.success("Removed from downloads", {
description: "The download has been removed successfully.",
});
},
onError: (error) => {
console.error("Failed to delete download state:", error);
toast({
title: 'Failed to remove download',
description: 'An error occurred while trying to remove the download.',
variant: "destructive"
})
toast.error("Failed to remove download", {
description: "An error occurred while trying to remove the download.",
});
}
})
}
const stopOngoingDownloads = async () => {
if (ongoingDownloads.length > 0) {
for (const state of ongoingDownloads) {
setIsPausingDownload(state.download_id, true);
try {
await pauseDownload(state);
} catch (e) {
console.error(e);
toast.error("Failed to stop download", {
description: `An error occurred while trying to stop the download for ${state.title}.`,
});
} finally {
setIsPausingDownload(state.download_id, false);
}
}
if (ongoingDownloads.length === 0) {
toast.success("Stopped ongoing downloads", {
description: "All ongoing downloads have been stopped successfully.",
});
}
} else {
toast.info("No ongoing downloads", {
description: "There are no ongoing downloads to stop.",
});
}
}
const handleSearch = async (url: string, isPlaylist: boolean) => {
try {
LOG.info('NEODLP', `Received search request from library for URL: ${url}`);
navigate('/');
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
setRequestedUrl(url);
setAutoSubmitSearch(true);
toast.info(`Initiating ${isPlaylist ? 'Playlist' : 'Video'} Search`, {
description: `Initiating search for the selected ${isPlaylist ? 'playlist' : 'video'}.`,
});
} catch (e) {
console.error(e);
toast.error("Failed to initiate search", {
description: "An error occurred while trying to initiate the search.",
});
}
}
return (
<div className="container mx-auto p-4 space-y-4">
<Heading title="Library" description="Manage all your downloads in one place" />
<div className="w-full fle flex-col">
<div className="flex w-full items-center gap-3 mb-2">
<CloudDownload className="size-5" />
<h3 className="text-nowrap font-semibold">Incomplete Downloads</h3>
</div>
<Separator orientation="horizontal" className="" />
</div>
<div className="w-full flex flex-col gap-2">
{incompleteDownloads.length > 0 ? (
incompleteDownloads.map((state) => {
const itemActionStates = downloadActions[state.download_id] || {
isResuming: false,
isPausing: false,
isCanceling: false,
isDeleteFileChecked: false,
};
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
{state.ext && (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2" />
)}
{state.ext.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
</span>
)}
</div>
<div className="w-full flex flex-col justify-between">
<div className="flex flex-col gap-1">
<h4>{state.title}</h4>
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
<IndeterminateProgress indeterminate={true} className="w-full" />
)}
{(state.download_status === 'downloading' || state.download_status === 'paused') && state.progress && state.status !== 'finished' && (
<div className="w-full flex items-center gap-2">
<span className="text-sm text-nowrap">{state.progress}%</span>
<Progress value={state.progress} />
<span className="text-sm text-nowrap">{
state.downloaded && state.total
? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})`
: null
}</span>
</div>
)}
<div className="text-xs text-muted-foreground">{ state.download_status && (
`${state.download_status === 'downloading' && state.status === 'finished' ? 'Processing' : state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)} ${state.download_status === 'downloading' && state.status !== 'finished' && state.speed ? `• Speed: ${formatSpeed(state.speed)}` : ""} ${state.download_status === 'downloading' && state.eta ? `• ETA: ${formatSecToTimeString(state.eta)}` : ""}`
)}</div>
</div>
<div className="w-full flex items-center gap-2 mt-2">
{state.download_status === 'paused' ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="w-full flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="completed">Completed {completedDownloads.length > 0 && (`(${completedDownloads.length})`)}</TabsTrigger>
<TabsTrigger value="incomplete">Incomplete {(incompleteDownloads.length > 0 && ongoingDownloads.length <= 0) && (`(${incompleteDownloads.length})`)} {ongoingDownloads.length > 0 && (<Badge className="h-4 min-w-4 rounded-full px-1 font-mono tabular-nums ml-1">{ongoingDownloads.length}</Badge>)}</TabsTrigger>
</TabsList>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsResumingDownload(state.download_id, true);
try {
await resumeDownload(state)
// toast({
// title: 'Resumed Download',
// description: 'Download resumed, it will re-start shortly.',
// })
} catch (e) {
console.error(e);
toast({
title: 'Failed to Resume Download',
description: 'An error occurred while trying to resume the download.',
variant: "destructive"
})
} finally {
setIsResumingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
>
{itemActionStates.isResuming ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Resuming
</>
) : (
<>
<Play className="w-4 h-4" />
Resume
</>
)}
</Button>
) : (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsPausingDownload(state.download_id, true);
try {
await pauseDownload(state)
// toast({
// title: 'Paused Download',
// description: 'Download paused successfully.',
// })
} catch (e) {
console.error(e);
toast({
title: 'Failed to Pause Download',
description: 'An error occurred while trying to pause the download.',
variant: "destructive"
})
} finally {
setIsPausingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isPausing || itemActionStates.isCanceling || state.download_status !== 'downloading' || (state.download_status === 'downloading' && state.status === 'finished')}
>
{itemActionStates.isPausing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Pausing
</>
) : (
<>
<Pause className="w-4 h-4" />
Pause
</>
)}
</Button>
)}
<Button
size="sm"
className="w-fit"
variant="destructive"
onClick={async () => {
setIsCancelingDownload(state.download_id, true);
try {
await cancelDownload(state)
toast({
title: 'Canceled Download',
description: 'Download canceled successfully.',
})
} catch (e) {
console.error(e);
toast({
title: 'Failed to Cancel Download',
description: 'An error occurred while trying to cancel the download.',
variant: "destructive"
})
} finally {
setIsCancelingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isCanceling || itemActionStates.isResuming || itemActionStates.isPausing || state.download_status === 'starting' || (state.download_status === 'downloading' && state.status === 'finished')}
size="sm"
disabled={ongoingDownloads.length <= 0}
>
{itemActionStates.isCanceling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Canceling
</>
) : (
<>
<X className="w-4 h-4" />
Cancel
</>
)}
<Square className="h-4 w-4" />
Stop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop all ongoing downloads?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to stop all ongoing downloads? This will pause all downloads including the download queue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => stopOngoingDownloads()}
>Stop</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)
})
) : (
<div className="w-full flex items-center justify-center text-muted-foreground text-sm">No Incomplete downloads!</div>
)}
</div>
<div className="w-full fle flex-col">
<div className="flex w-full items-center gap-3 mb-2">
<PackageCheck className="size-5" />
<h3 className="text-nowrap font-semibold">Completed Downloads</h3>
</div>
<Separator orientation="horizontal" className="" />
</div>
<TabsContent value="completed">
<div className="w-full flex flex-col gap-2">
{completedDownloads.length > 0 ? (
completedDownloads.map((state) => {
@@ -380,7 +282,11 @@ export default function LibraryPage() {
</Button>
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
<FolderInput className="w-4 h-4" />
Open in Explorer
Reveal
</Button>
<Button size="sm" variant="outline" onClick={() => handleSearch(state.url, state.playlist_id ? true : false)}>
<Search className="w-4 h-4" />
Search
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -391,9 +297,9 @@ export default function LibraryPage() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>Remove from library?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone! it will permanently remove this from downloads.
Are you sure you want to remove this download from the library? You can also delete the downloaded file by cheking the box below. This action cannot be undone.
</AlertDialogDescription>
<div className="flex items-center space-x-2">
<Checkbox id="delete-file" checked={itemActionStates.isDeleteFileChecked} onCheckedChange={() => {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} />
@@ -416,9 +322,184 @@ export default function LibraryPage() {
)
})
) : (
<div className="w-full flex items-center justify-center text-muted-foreground text-sm">No Completed downloads!</div>
<div className="w-full flex flex-col items-center gap-2 justify-center mt-27">
<h4 className="text-4xl font-bold text-muted-foreground/80 dark:text-muted">Nothing!</h4>
<p className="text-lg font-semibold text-muted-foreground/50">No Completed Downloads</p>
<p className="max-w-[50%] text-center text-xs text-muted-foreground/70">You have not completed any downloads yet. Complete downloading something to see here :)</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="incomplete">
<div className="w-full flex flex-col gap-2">
{incompleteDownloads.length > 0 ? (
incompleteDownloads.map((state) => {
const itemActionStates = downloadActions[state.download_id] || {
isResuming: false,
isPausing: false,
isCanceling: false,
isDeleteFileChecked: false,
};
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
{state.ext && (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2" />
)}
{state.ext.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
</span>
)}
</div>
<div className="w-full flex flex-col justify-between">
<div className="flex flex-col gap-1">
<h4>{state.title}</h4>
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
<IndeterminateProgress indeterminate={true} className="w-full" />
)}
{(state.download_status === 'downloading' || state.download_status === 'paused') && state.progress && state.status !== 'finished' && (
<div className="w-full flex items-center gap-2">
<span className="text-sm text-nowrap">{state.progress}%</span>
<Progress value={state.progress} />
<span className="text-sm text-nowrap">{
state.downloaded && state.total
? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})`
: null
}</span>
</div>
)}
<div className="text-xs text-muted-foreground">{ state.download_status && (
`${state.download_status === 'downloading' && state.status === 'finished' ? 'Processing' : state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)} ${state.download_id ? `• ID: ${state.download_id.toUpperCase()}` : ""} ${state.download_status === 'downloading' && state.status !== 'finished' && state.speed ? `• Speed: ${formatSpeed(state.speed)}` : ""} ${state.download_status === 'downloading' && state.eta ? `• ETA: ${formatSecToTimeString(state.eta)}` : ""}`
)}</div>
</div>
<div className="w-full flex items-center gap-2 mt-2">
{state.download_status === 'paused' ? (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsResumingDownload(state.download_id, true);
try {
await resumeDownload(state)
// toast.success("Resumed Download", {
// description: "Download resumed, it will re-start shortly.",
// })
} catch (e) {
console.error(e);
toast.error("Failed to Resume Download", {
description: "An error occurred while trying to resume the download.",
})
} finally {
setIsResumingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
>
{itemActionStates.isResuming ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Resuming
</>
) : (
<>
<Play className="w-4 h-4" />
Resume
</>
)}
</Button>
) : (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsPausingDownload(state.download_id, true);
try {
await pauseDownload(state)
// toast.success("Paused Download", {
// description: "Download paused successfully.",
// })
} catch (e) {
console.error(e);
toast.error("Failed to Pause Download", {
description: "An error occurred while trying to pause the download."
})
} finally {
setIsPausingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isPausing || itemActionStates.isCanceling || state.download_status !== 'downloading' || (state.download_status === 'downloading' && state.status === 'finished')}
>
{itemActionStates.isPausing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Pausing
</>
) : (
<>
<Pause className="w-4 h-4" />
Pause
</>
)}
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={async () => {
setIsCancelingDownload(state.download_id, true);
try {
await cancelDownload(state)
toast.success("Canceled Download", {
description: "Download canceled successfully.",
})
} catch (e) {
console.error(e);
toast.error("Failed to Cancel Download", {
description: "An error occurred while trying to cancel the download.",
})
} finally {
setIsCancelingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isCanceling || itemActionStates.isResuming || itemActionStates.isPausing || state.download_status === 'starting' || (state.download_status === 'downloading' && state.status === 'finished')}
>
{itemActionStates.isCanceling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Canceling
</>
) : (
<>
<X className="w-4 h-4" />
Cancel
</>
)}
</Button>
</div>
</div>
</div>
)
})
) : (
<div className="w-full flex flex-col items-center gap-2 justify-center mt-27">
<h4 className="text-4xl font-bold text-muted-foreground/80 dark:text-muted">Nothing!</h4>
<p className="text-lg font-semibold text-muted-foreground/50">No Incomplete Downloads</p>
<p className="max-w-[50%] text-center text-xs text-muted-foreground/70">You have all caught up! Sit back and relax or just spin up a new download to see here :)</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { DownloadState } from '@/types/download';
import { DownloadConfiguration } from '@/types/settings';
import { RawVideoInfo } from '@/types/video';
import { createContext, useContext } from 'react';
interface AppContextType {
fetchVideoMetadata: (url: string, formatId?: string) => Promise<RawVideoInfo | null>;
startDownload: (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => Promise<void>;
fetchVideoMetadata: (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState, downloadConfig?: DownloadConfiguration) => Promise<RawVideoInfo | null>;
startDownload: (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => Promise<void>;
pauseDownload: (state: DownloadState) => Promise<void>;
resumeDownload: (state: DownloadState) => Promise<void>;
cancelDownload: (state: DownloadState) => Promise<void>;

View File

@@ -1,4 +1,4 @@
import { Download, Puzzle, Settings, SquarePlay } from "lucide-react";
import { Download, Settings, SquarePlay } from "lucide-react";
import { RoutesObj } from "@/types/route";
export const AllRoutes: Array<RoutesObj> = [
@@ -12,11 +12,6 @@ export const AllRoutes: Array<RoutesObj> = [
url: "/library",
icon: SquarePlay,
},
{
title: "Extension",
url: "/extension",
icon: Puzzle,
},
{
title: "Settings",
url: "/settings",

View File

@@ -196,7 +196,15 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
eta = $22,
filepath = $23,
filetype = $24,
filesize = $25
filesize = $25,
output_format = $26,
embed_metadata = $27,
embed_thumbnail = $28,
sponsorblock_remove = $29,
sponsorblock_mark = $30,
use_aria2 = $31,
custom_command = $32,
queue_config = $33
WHERE download_id = $1`,
[
downloadState.download_id,
@@ -223,7 +231,15 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.eta,
downloadState.filepath,
downloadState.filetype,
downloadState.filesize
downloadState.filesize,
downloadState.output_format,
downloadState.embed_metadata,
downloadState.embed_thumbnail,
downloadState.sponsorblock_remove,
downloadState.sponsorblock_mark,
downloadState.use_aria2,
downloadState.custom_command,
downloadState.queue_config
]
)
}
@@ -252,8 +268,16 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
eta,
filepath,
filetype,
filesize
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`,
filesize,
output_format,
embed_metadata,
embed_thumbnail,
sponsorblock_remove,
sponsorblock_mark,
use_aria2,
custom_command,
queue_config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)`,
[
downloadState.download_id,
downloadState.download_status,
@@ -279,7 +303,15 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.eta,
downloadState.filepath,
downloadState.filetype,
downloadState.filesize
downloadState.filesize,
downloadState.output_format,
downloadState.embed_metadata,
downloadState.embed_thumbnail,
downloadState.sponsorblock_remove,
downloadState.sponsorblock_mark,
downloadState.use_aria2,
downloadState.custom_command,
downloadState.queue_config
]
)
}
@@ -292,11 +324,11 @@ export const updateDownloadStatus = async (download_id: string, download_status:
)
}
export const updateDownloadFilePath = async (download_id: string, filepath: string) => {
export const updateDownloadFilePath = async (download_id: string, filepath: string, ext: string) => {
const db = await Database.load('sqlite:database.db')
return await db.execute(
'UPDATE downloads SET filepath = $2 WHERE download_id = $1',
[download_id, filepath]
'UPDATE downloads SET filepath = $2, ext = $3 WHERE download_id = $1',
[download_id, filepath, ext]
)
}

View File

@@ -31,8 +31,8 @@ export function useUpdateDownloadStatus() {
export function useUpdateDownloadFilePath() {
return useMutation({
mutationFn: (data: { download_id: string; filepath: string }) =>
updateDownloadFilePath(data.download_id, data.filepath)
mutationFn: (data: { download_id: string; filepath: string, ext: string }) =>
updateDownloadFilePath(data.download_id, data.filepath, data.ext)
})
}

View File

@@ -1,4 +1,4 @@
import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, KvPairsStatesStore, SettingsPageStatesStore } from '@/types/store';
import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, KvPairsStatesStore, LibraryPageStatesStore, LogsStore, SettingsPageStatesStore } from '@/types/store';
import { create } from 'zustand';
export const useBasePathsStore = create<BasePathsStore>((set) => ({
@@ -34,22 +34,66 @@ export const useCurrentVideoMetadataStore = create<CurrentVideoMetadataStore>((s
isMetadataLoading: false,
requestedUrl: '',
autoSubmitSearch: false,
searchPid: null,
showSearchError: true,
setVideoUrl: (url) => set(() => ({ videoUrl: url })),
setVideoMetadata: (metadata) => set(() => ({ videoMetadata: metadata })),
setIsMetadataLoading: (isLoading) => set(() => ({ isMetadataLoading: isLoading })),
setRequestedUrl: (url) => set(() => ({ requestedUrl: url })),
setAutoSubmitSearch: (autoSubmit) => set(() => ({ autoSubmitSearch: autoSubmit })),
setSearchPid: (pid) => set(() => ({ searchPid: pid })),
setShowSearchError: (showError) => set(() => ({ showSearchError: showError }))
}));
export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({
activeDownloadModeTab: 'selective',
activeDownloadConfigurationTab: 'options',
isStartingDownload: false,
selctedDownloadFormat: 'best',
selectedDownloadFormat: 'best',
selectedCombinableVideoFormat: '',
selectedCombinableAudioFormat: '',
selectedSubtitles: [],
selectedPlaylistVideoIndex: '1',
downloadConfiguration: {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
isErrored: false,
isErrorExpected: false,
erroredDownloadId: null,
setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })),
setActiveDownloadConfigurationTab: (tab) => set(() => ({ activeDownloadConfigurationTab: tab })),
setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })),
setSelctedDownloadFormat: (format) => set(() => ({ selctedDownloadFormat: format })),
setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })),
setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })),
setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })),
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index }))
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })),
setDownloadConfigurationKey: (key, value) => set((state) => ({
downloadConfiguration: {
...state.downloadConfiguration,
[key]: value
}
})),
setDownloadConfiguration: (config) => set(() => ({ downloadConfiguration: config })),
resetDownloadConfiguration: () => set(() => ({
downloadConfiguration: {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
}
})),
setIsErrored: (isErrored) => set(() => ({ isErrored: isErrored })),
setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })),
setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })),
}));
export const useLibraryPageStatesStore = create<LibraryPageStatesStore>((set) => ({
activeTab: 'completed',
setActiveTab: (tab) => set(() => ({ activeTab: tab }))
}));
export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((set) => ({
@@ -93,7 +137,9 @@ export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((s
}));
export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set) => ({
activeTab: 'general',
activeTab: 'app',
activeSubAppTab: 'general',
activeSubExtTab: 'install',
appVersion: null,
isFetchingAppVersion: false,
ytDlpVersion: null,
@@ -105,9 +151,36 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
theme: 'system',
download_dir: '',
prefer_video_over_playlist: true,
strict_downloadablity_check: false,
max_parallel_downloads: 2,
max_retries: 5,
use_proxy: false,
proxy_url: '',
use_rate_limit: false,
rate_limit: 1048576, // 1 MB/s
video_format: 'auto',
audio_format: 'auto',
always_reencode_video: false,
embed_video_metadata: false,
embed_audio_metadata: true,
embed_audio_thumbnail: true,
use_cookies: false,
import_cookies_from: 'browser',
cookies_browser: 'firefox',
cookies_file: '',
use_sponsorblock: false,
sponsorblock_mode: 'remove',
sponsorblock_remove: 'default',
sponsorblock_mark: 'default',
sponsorblock_remove_categories: [],
sponsorblock_mark_categories: [],
use_aria2: false,
use_force_internet_protocol: false,
force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
filename_template: '%(title)s_%(resolution|unknown)s',
// extension settings
websocket_port: 53511
},
isUsingDefaultSettings: true,
@@ -118,6 +191,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
isUpdatingApp: false,
appUpdateDownloadProgress: 0,
setActiveTab: (tab) => set(() => ({ activeTab: tab })),
setActiveSubAppTab: (tab) => set(() => ({ activeSubAppTab: tab })),
setActiveSubExtTab: (tab) => set(() => ({ activeSubExtTab: tab })),
setAppVersion: (version) => set(() => ({ appVersion: version })),
setIsFetchingAppVersion: (isFetching) => set(() => ({ isFetchingAppVersion: isFetching })),
setYtDlpVersion: (version) => set(() => ({ ytDlpVersion: version })),
@@ -137,9 +212,36 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
theme: 'system',
download_dir: '',
prefer_video_over_playlist: true,
strict_downloadablity_check: false,
max_parallel_downloads: 2,
max_retries: 5,
use_proxy: false,
proxy_url: '',
use_rate_limit: false,
rate_limit: 1048576, // 1 MB/s
video_format: 'auto',
audio_format: 'auto',
always_reencode_video: false,
embed_video_metadata: false,
embed_audio_metadata: true,
embed_audio_thumbnail: true,
use_cookies: false,
import_cookies_from: 'browser',
cookies_browser: 'firefox',
cookies_file: '',
use_sponsorblock: false,
sponsorblock_mode: 'remove',
sponsorblock_remove: 'default',
sponsorblock_mark: 'default',
sponsorblock_remove_categories: [],
sponsorblock_mark_categories: [],
use_aria2: false,
use_force_internet_protocol: false,
force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
filename_template: '%(title)s_%(resolution|unknown)s',
// extension settings
websocket_port: 53511
},
isUsingDefaultSettings: true
@@ -166,3 +268,10 @@ export const useKvPairsStatesStore = create<KvPairsStatesStore>((set) => ({
})),
setKvPairs: (kvPairs) => set(() => ({ kvPairs }))
}));
export const useLogsStore = create<LogsStore>((set) => ({
logs: [],
setLogs: (logs) => set(() => ({ logs })),
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
clearLogs: () => set(() => ({ logs: [] }))
}));

View File

@@ -37,6 +37,16 @@ export interface DownloadState {
filepath: string | null;
filetype: string | null;
filesize: number | null;
output_format: string | null;
embed_metadata: number;
embed_thumbnail: number;
sponsorblock_remove: string | null;
sponsorblock_mark: string | null;
use_aria2: number;
custom_command: string | null;
queue_config: string | null;
created_at?: string;
updated_at?: string;
}
export interface Download {
@@ -65,6 +75,16 @@ export interface Download {
filepath: string | null;
filetype: string | null;
filesize: number | null;
output_format: string | null;
embed_metadata: number;
embed_thumbnail: number;
sponsorblock_remove: string | null;
sponsorblock_mark: string | null;
use_aria2: number;
custom_command: string | null;
queue_config: string | null;
created_at: string;
updated_at: string;
}
export interface DownloadProgress {

6
src/types/logs.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface Log {
timestamp: number;
level: 'info' | 'warning' | 'error' | 'debug';
context: string;
message: string;
}

View File

@@ -3,14 +3,54 @@ export interface SettingsTable {
value: string;
}
export interface CustomCommand {
id: string;
label: string;
args: string;
}
export interface Settings {
ytdlp_update_channel: string;
ytdlp_auto_update: boolean;
theme: 'dark' | 'light' | 'system';
download_dir: string;
max_parallel_downloads: number;
max_retries: number;
prefer_video_over_playlist: boolean;
strict_downloadablity_check: boolean;
use_proxy: boolean;
proxy_url: string;
use_rate_limit: boolean;
rate_limit: number;
video_format: string;
audio_format: string;
always_reencode_video: boolean;
embed_video_metadata: boolean;
embed_audio_metadata: boolean;
embed_audio_thumbnail: boolean;
use_cookies: boolean;
import_cookies_from: string;
cookies_browser: string;
cookies_file: string;
use_sponsorblock: boolean;
sponsorblock_mode: string;
sponsorblock_remove: string;
sponsorblock_mark: string;
sponsorblock_remove_categories: string[];
sponsorblock_mark_categories: string[];
use_aria2: boolean;
use_force_internet_protocol: boolean;
force_internet_protocol: string;
use_custom_commands: boolean;
custom_commands: CustomCommand[];
filename_template: string;
// extension settings
websocket_port: number;
}
export interface DownloadConfiguration {
output_format: string | null;
embed_metadata: boolean | null;
embed_thumbnail: boolean | null;
custom_command: string | null;
}

View File

@@ -1,8 +1,9 @@
import { DownloadState } from "@/types/download";
import { RawVideoInfo } from "@/types/video";
import { Settings } from "@/types/settings";
import { DownloadConfiguration, Settings } from "@/types/settings";
import { KvStore } from "@/types/kvStore";
import { Update } from "@tauri-apps/plugin-updater";
import { Log } from "@/types/logs";
export interface BasePathsStore {
ffmpegPath: string | null;
@@ -23,22 +24,49 @@ export interface CurrentVideoMetadataStore {
isMetadataLoading: boolean;
requestedUrl: string;
autoSubmitSearch: boolean;
searchPid: number | null;
showSearchError: boolean;
setVideoUrl: (url: string) => void;
setVideoMetadata: (metadata: RawVideoInfo | null) => void;
setIsMetadataLoading: (isLoading: boolean) => void;
setRequestedUrl: (url: string) => void;
setAutoSubmitSearch: (autoSubmit: boolean) => void;
setSearchPid: (pid: number | null) => void;
setShowSearchError: (showError: boolean) => void;
}
export interface DownloaderPageStatesStore {
activeDownloadModeTab: string;
activeDownloadConfigurationTab: string;
isStartingDownload: boolean;
selctedDownloadFormat: string;
selectedDownloadFormat: string;
selectedCombinableVideoFormat: string;
selectedCombinableAudioFormat: string;
selectedSubtitles: string[];
selectedPlaylistVideoIndex: string;
downloadConfiguration: DownloadConfiguration;
isErrored: boolean;
isErrorExpected: boolean;
erroredDownloadId: string | null;
setActiveDownloadModeTab: (tab: string) => void;
setActiveDownloadConfigurationTab: (tab: string) => void;
setIsStartingDownload: (isStarting: boolean) => void;
setSelctedDownloadFormat: (format: string) => void;
setSelectedDownloadFormat: (format: string) => void;
setSelectedCombinableVideoFormat: (format: string) => void;
setSelectedCombinableAudioFormat: (format: string) => void;
setSelectedSubtitles: (subtitles: string[]) => void;
setSelectedPlaylistVideoIndex: (index: string) => void;
setDownloadConfigurationKey: (key: string, value: unknown) => void;
setDownloadConfiguration: (config: DownloadConfiguration) => void;
resetDownloadConfiguration: () => void;
setIsErrored: (isErrored: boolean) => void;
setIsErrorExpected: (isErrorExpected: boolean) => void;
setErroredDownloadId: (downloadId: string | null) => void;
}
export interface LibraryPageStatesStore {
activeTab: string;
setActiveTab: (tab: string) => void;
}
export interface DownloadActionStatesStore {
@@ -58,6 +86,8 @@ export interface DownloadActionStatesStore {
export interface SettingsPageStatesStore {
activeTab: string;
activeSubAppTab: string;
activeSubExtTab: string;
appVersion: string | null;
isFetchingAppVersion: boolean;
ytDlpVersion: string | null;
@@ -72,6 +102,8 @@ export interface SettingsPageStatesStore {
isUpdatingApp: boolean;
appUpdateDownloadProgress: number;
setActiveTab: (tab: string) => void;
setActiveSubAppTab: (tab: string) => void;
setActiveSubExtTab: (tab: string) => void;
setAppVersion: (version: string | null) => void;
setIsFetchingAppVersion: (isFetching: boolean) => void;
setYtDlpVersion: (version: string | null) => void;
@@ -94,3 +126,10 @@ export interface KvPairsStatesStore {
setKvPairsKey: (key: string, value: unknown) => void;
setKvPairs: (kvPairs: KvStore) => void;
}
export interface LogsStore {
logs: Log[];
setLogs: (logs: Log[]) => void;
addLog: (log: Log) => void;
clearLogs: () => void;
}

View File

@@ -21,14 +21,37 @@ export function getRouteName(location: string, routes: Array<RoutesObj> = AllRou
return lastPart ? lastPart.toUpperCase() : 'Dashboard';
}
const convertToBytes = (value: number, unit: string): number => {
switch (unit) {
case 'B':
return value;
case 'KiB':
return value * 1024;
case 'MiB':
return value * 1024 * 1024;
case 'GiB':
return value * 1024 * 1024 * 1024;
default:
return value;
}
};
export const parseProgressLine = (line: string): DownloadProgress => {
const progress: Partial<DownloadProgress> = {
status: 'downloading'
};
line.split(',').forEach(pair => {
// Check if line contains both aria2c and yt-dlp format (combined format)
if (line.includes(']status:')) {
// Extract the status part after the closing bracket
const statusIndex = line.indexOf(']status:');
if (statusIndex !== -1) {
const statusPart = line.substring(statusIndex + 1); // +1 to skip the ']'
// Parse the yt-dlp format part
statusPart.split(',').forEach(pair => {
const [key, value] = pair.split(':');
switch (key) {
if (key && value) {
switch (key.trim()) {
case 'status':
progress.status = value.trim();
break;
@@ -45,9 +68,84 @@ export const parseProgressLine = (line: string): DownloadProgress => {
progress.total = parseInt(value, 10);
break;
case 'eta':
if (value.trim() !== 'NA') {
progress.eta = parseInt(value, 10);
}
break;
}
}
});
}
return progress as DownloadProgress;
}
// Check if line is aria2c format only
if (line.startsWith('[#') && line.includes('MiB') && line.includes('%')) {
// Parse aria2c format: [#99f72b 2.5MiB/3.4MiB(75%) CN:1 DL:503KiB ETA:1s]
// Extract progress percentage
const progressMatch = line.match(/\((\d+(?:\.\d+)?)%\)/);
if (progressMatch) {
progress.progress = parseFloat(progressMatch[1]);
}
// Extract downloaded/total sizes
const sizeMatch = line.match(/(\d+(?:\.\d+)?)(MiB|KiB|GiB|B)\/(\d+(?:\.\d+)?)(MiB|KiB|GiB|B)/);
if (sizeMatch) {
const downloaded = parseFloat(sizeMatch[1]);
const downloadedUnit = sizeMatch[2];
const total = parseFloat(sizeMatch[3]);
const totalUnit = sizeMatch[4];
// Convert to bytes
progress.downloaded = convertToBytes(downloaded, downloadedUnit);
progress.total = convertToBytes(total, totalUnit);
}
// Extract download speed
const speedMatch = line.match(/DL:(\d+(?:\.\d+)?)(KiB|MiB|GiB|B)/);
if (speedMatch) {
const speed = parseFloat(speedMatch[1]);
const speedUnit = speedMatch[2];
progress.speed = convertToBytes(speed, speedUnit);
}
// Extract ETA
const etaMatch = line.match(/ETA:(\d+)s/);
if (etaMatch) {
progress.eta = parseInt(etaMatch[1], 10);
}
return progress as DownloadProgress;
}
// Original yt-dlp format: status:downloading,progress: 75.1%,speed:1022692.427018,downloaded:30289474,total:40331784,eta:9
line.split(',').forEach(pair => {
const [key, value] = pair.split(':');
if (key && value) {
switch (key.trim()) {
case 'status':
progress.status = value.trim();
break;
case 'progress':
progress.progress = parseFloat(value.replace('%', '').trim());
break;
case 'speed':
progress.speed = parseFloat(value);
break;
case 'downloaded':
progress.downloaded = parseInt(value, 10);
break;
case 'total':
progress.total = parseInt(value, 10);
break;
case 'eta':
if (value.trim() !== 'NA') {
progress.eta = parseInt(value, 10);
}
break;
}
}
});
return progress as DownloadProgress;
@@ -111,6 +209,10 @@ export const formatCodec = (codec: string) => {
return codec.toUpperCase();
}
export const generateID = () => {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export const generateDownloadId = (videoId: string, host: string) => {
host = host.trim().split('.')[0];
return `${host}_${videoId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

View File

@@ -21,7 +21,6 @@
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}

View File

@@ -9,7 +9,9 @@ const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
build: {
chunkSizeWarningLimit: 1024, // 1MB
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors