Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5020605590 | ||
|
|
b15c3d8ce5 | ||
|
|
a51655697d | ||
|
|
f5c26f5f02 | ||
|
|
3bc9d76f6e | ||
|
|
5751d73d5b | ||
|
|
50bd9f1659 | ||
|
|
e7627e1132 | ||
|
|
d7b131d795 | ||
|
|
74dbb223ce | ||
|
|
11a931f865 | ||
|
|
79a78d4322 | ||
|
|
a1c44baf65 | ||
|
|
86e6b93476 | ||
|
|
dbcd77963f | ||
|
|
58a4d2da36 | ||
|
|
db16d5d051 | ||
|
|
41f40b6e71 | ||
|
|
1ee717888f | ||
|
|
c7815eb81a | ||
|
|
3946b5ef2f | ||
|
|
ef99eb1f59 | ||
|
|
31607bd947 | ||
|
|
452132ce16 | ||
|
|
f84f3ef02a | ||
|
|
6c3f070797 | ||
|
|
62a339e5b9 | ||
|
|
f1fd40176f | ||
|
|
e5d8629ba3 | ||
|
|
3375f94848 | ||
|
|
9f7206abfe | ||
|
|
334c7426c2 | ||
|
|
bd7f4a433c | ||
|
|
27287238aa | ||
|
|
486767fca9 | ||
|
|
e22fe7679a | ||
|
|
7c2e526c49 | ||
|
|
2f5776d1e0 | ||
|
|
7901fc7e82 | ||
|
|
612800bef2 | ||
|
|
41b58a6f70 | ||
|
|
1a8e26a847 | ||
|
|
0bcddddc37 | ||
|
|
99163865e2 | ||
|
|
40113a2b53 | ||
|
|
75c8b19f31 | ||
|
|
2a28b0db85 | ||
|
|
ca4b851ba4 | ||
|
|
0742dac057 | ||
|
|
9d2e35c322 | ||
|
|
526bd994d8 | ||
|
|
37c1f22aee | ||
|
|
10a97b1f4a | ||
|
|
11eaaf0f88 | ||
|
|
512b9d1175 | ||
|
|
a1307bdd0b | ||
|
|
06132a0704 | ||
|
|
a7e4eb865b | ||
|
|
5246ab8369 | ||
|
|
baa887da2e | ||
|
|
be40f087ad | ||
|
|
ad62bd07aa | ||
|
|
365b9c0660 | ||
|
|
a1f5e0480c | ||
|
|
00a7950e6a | ||
|
|
598ed9c964 | ||
|
|
2b3edab4eb | ||
|
|
d0bdfce803 | ||
|
|
17a4568492 | ||
|
|
840a432b7f | ||
|
|
cc17e29fbe | ||
|
|
dd5f0a729b |
133
.github/workflows/build.yml
vendored
Normal file
133
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload_artifacts:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows
|
||||
os: windows-latest
|
||||
build_script: pnpm run build:win
|
||||
ytdlp_asset: yt-dlp.exe
|
||||
ytdlp_output: yt-dlp.exe
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
|
||||
ffmpeg_output: ffmpeg.exe
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
build_script: pnpm run build:mac
|
||||
ytdlp_asset: yt-dlp_macos
|
||||
ytdlp_output: yt-dlp_macos
|
||||
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
|
||||
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
|
||||
ffmpeg_inner_path: ffmpeg/ffmpeg
|
||||
ffmpeg_output: ffmpeg_macos
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
build_script: pnpm run build:linux
|
||||
ytdlp_asset: yt-dlp
|
||||
ytdlp_output: yt-dlp_linux
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
|
||||
ffmpeg_output: ffmpeg_linux
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download ffmpeg binary (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ffmpegUrl = '${{ matrix.ffmpeg_url }}'
|
||||
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
|
||||
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
|
||||
- name: Download ffmpeg binary (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||
|
||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$x86_bin" ]]; then
|
||||
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
|
||||
chmod +x "resources/$FFMPEG_OUTPUT"
|
||||
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
|
||||
|
||||
- name: Download ffmpeg binary (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
mkdir ffmpeg
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||
fi
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check && pnpm run typecheck
|
||||
|
||||
- name: Build application
|
||||
run: ${{ matrix.build_script }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: inputs.upload_artifacts == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -5,31 +5,5 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build check
|
||||
run: pnpm run build
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
|
||||
62
.github/workflows/release.yml
vendored
62
.github/workflows/release.yml
vendored
@@ -6,62 +6,29 @@ on:
|
||||
- v*.*.*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: 20
|
||||
pattern: dist-*
|
||||
merge-multiple: true
|
||||
path: dist/
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download yt-dlp binaries
|
||||
run: |
|
||||
# Download yt-dlp.exe for Windows
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
||||
|
||||
# Download yt-dlp for macOS (for cross-platform builds)
|
||||
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
|
||||
# chmod +x resources/yt-dlp_macos
|
||||
|
||||
# Download yt-dlp for Linux (for cross-platform builds)
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
||||
chmod +x resources/yt-dlp_linux
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
|
||||
# - name: build-linux
|
||||
# if: matrix.os == 'ubuntu-latest'
|
||||
# run: pnpm run build:linux
|
||||
|
||||
# - name: build-mac
|
||||
# if: matrix.os == 'macos-latest'
|
||||
# run: pnpm run build:mac
|
||||
|
||||
- name: build-win
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: pnpm run build:win
|
||||
|
||||
- name: release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
@@ -75,4 +42,3 @@ jobs:
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
|
||||
29
.github/workflows/translator.yaml
vendored
Normal file
29
.github/workflows/translator.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: 'translator'
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
discussion:
|
||||
types: [created, edited]
|
||||
discussion_comment:
|
||||
types: [created, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
IS_MODIFY_TITLE: true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -25,5 +25,8 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
3. Support i18n, only translate the English version of en.json
|
||||
4. use English for comments&console
|
||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
||||
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
|
||||
|
||||
@@ -58,6 +58,7 @@ src/
|
||||
- Build production bundles with `pnpm build`.
|
||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
||||
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
|
||||
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
|
||||
|
||||
## Working on Changes
|
||||
- Keep each pull request focused on a single problem or feature.
|
||||
|
||||
166
README.md
166
README.md
@@ -1,81 +1,141 @@
|
||||
# 🐝 VidBee
|
||||
|
||||
<div align="center">
|
||||
<img src="build/icon.png" alt="VidBee icon" width="120" />
|
||||
<h3>Download videos from almost any website worldwide</h3>
|
||||
<p>Best-in-class UI interface - Clean, intuitive, and powerful</p>
|
||||
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
|
||||
<a href="https://github.com/nexmoe/VidBee">
|
||||
<img src="build/icon.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3>VidBee</h3>
|
||||
<p>
|
||||
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
|
||||
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
|
||||
<br />
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## ✨ Core Features
|
||||
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
|
||||
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
Feel free to try it using the following methods:
|
||||
|
||||
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
|
||||
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
|
||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||
|
||||
### 🌐 Browser Script (Quick Download)
|
||||
|
||||
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
|
||||
|
||||

|
||||
|
||||
**Installation:**
|
||||
|
||||
1. Install a userscript manager extension:
|
||||
- [Tampermonkey](https://www.tampermonkey.net/) (Recommended)
|
||||
- [Violentmonkey](https://violentmonkey.github.io/)
|
||||
- [Greasemonkey](https://www.greasespot.net/)
|
||||
|
||||
2. Install the VidBee Quick Download script:
|
||||
- [Install from Greasy Fork](https://greasyfork.org/zh-CN/scripts/559595-vidbee-quick-download)
|
||||
|
||||
**Usage:**
|
||||
|
||||
After installation, when you visit supported video websites (YouTube, Bilibili, TikTok, Instagram, Twitter, etc.), a download button will appear on the page. Click the button to quickly send the video URL to VidBee desktop app for downloading.
|
||||
|
||||
**Supported Sites:**
|
||||
|
||||
The script works on popular video platforms including:
|
||||
|
||||
- YouTube, Bilibili, TikTok, Vimeo, Dailymotion
|
||||
- Twitch, Twitter/X, Instagram, Facebook
|
||||
- Reddit, SoundCloud, Niconico, Kick
|
||||
- Bandcamp, Mixcloud, and more
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
|
||||
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🌍 Global Video Download Support
|
||||
|
||||
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
|
||||
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
|
||||
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
|
||||
- Localized interface support in many languages
|
||||
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
|
||||
|
||||

|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
|
||||
- **Modern Design** - Clean and beautiful interface
|
||||
- **Intuitive Operations** - One-click pause/resume/retry
|
||||
- **Real-time Progress** - Detailed download progress tracking and status management
|
||||
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
|
||||
|
||||
## 📥 Download & Install
|
||||
|
||||
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
|
||||
2. **Choose your platform**:
|
||||
- **Windows**: Download `vidbee-x.x.x-setup.exe`
|
||||
- **macOS**: Download `vidbee-x.x.x.dmg`
|
||||
- **Linux**: Download `vidbee-x.x.x.AppImage`
|
||||
3. **Install and run** the application
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||
*Clean and intuitive interface with download queue management*
|
||||
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
|
||||
|
||||

|
||||
*Comprehensive download queue with progress tracking and status management*
|
||||
|
||||
### 📡 RSS Auto Download
|
||||
|
||||
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
|
||||
|
||||
## 🌐 Supported Sites
|
||||
|
||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||
|
||||
| Video Platforms | Audio & Other Platforms |
|
||||
| --- | --- |
|
||||
| YouTube | YouTube Music |
|
||||
| TikTok | SoundCloud |
|
||||
| Facebook | Mixcloud |
|
||||
| Instagram | Bandcamp |
|
||||
| X (Twitter) | Reddit |
|
||||
| Vimeo | |
|
||||
| Dailymotion | |
|
||||
| Twitch | |
|
||||
| LinkedIn | |
|
||||
| Pinterest | |
|
||||
| Tumblr | |
|
||||
| Niconico | |
|
||||
| Kick | |
|
||||
| :--------------- | :---------------------- |
|
||||
| YouTube | YouTube Music |
|
||||
| TikTok | SoundCloud |
|
||||
| Facebook | Mixcloud |
|
||||
| Instagram | Bandcamp |
|
||||
| X (Twitter) | Reddit |
|
||||
| Vimeo | |
|
||||
| Dailymotion | |
|
||||
| Twitch | |
|
||||
| LinkedIn | |
|
||||
| Pinterest | |
|
||||
| Tumblr | |
|
||||
| Niconico | |
|
||||
| Kick | |
|
||||
|
||||
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
## ?? Contributing
|
||||
## 🤝 Contributing
|
||||
|
||||
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
|
||||
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is distributed under the MIT License. See `LICENSE` for details.
|
||||
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [Electron](https://www.electronjs.org/)
|
||||
- [React](https://react.dev/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
|
||||
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
|
||||
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
|
||||
- [React](https://react.dev/) - The UI library
|
||||
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
|
||||
|
||||
13
biome.json
13
biome.json
@@ -39,7 +39,18 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
"includes": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.json",
|
||||
"!monkey/dist",
|
||||
"!dist",
|
||||
"!out",
|
||||
"!build"
|
||||
],
|
||||
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.icns
Binary file not shown.
6
conductor.json
Normal file
6
conductor.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "cp $CONDUCTOR_ROOT_PATH/resources/ resources/ && pnpm install",
|
||||
"run": "pnpm run dev"
|
||||
}
|
||||
}
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/main/lib/database/schema.ts',
|
||||
out: './resources/drizzle',
|
||||
dialect: 'sqlite'
|
||||
})
|
||||
@@ -2,6 +2,10 @@ appId: com.vidbee
|
||||
productName: VidBee
|
||||
directories:
|
||||
buildResources: build
|
||||
protocols:
|
||||
- name: VidBee
|
||||
schemes:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
@@ -19,16 +23,24 @@ nsis:
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
identity: null
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: yourname@example.com
|
||||
maintainer: nexmoex@gmail.com
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
@@ -38,3 +50,5 @@ publish:
|
||||
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
electronLanguages:
|
||||
- en
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
23
monkey/.gitignore
vendored
Normal file
23
monkey/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
// ==UserScript==
|
||||
// @name vidbee-quick-download
|
||||
// @namespace vidbee
|
||||
// @version 0.0.1
|
||||
// @icon https://vidbee.org/favicon.svg
|
||||
// @match https://www.youtube.com/*
|
||||
// @match https://youtube.com/*
|
||||
// @match https://music.youtube.com/*
|
||||
// @match https://www.bilibili.com/*
|
||||
// @match https://bilibili.com/*
|
||||
// @match https://www.tiktok.com/*
|
||||
// @match https://tiktok.com/*
|
||||
// @match https://vimeo.com/*
|
||||
// @match https://www.vimeo.com/*
|
||||
// @match https://www.dailymotion.com/*
|
||||
// @match https://dailymotion.com/*
|
||||
// @match https://www.twitch.tv/*
|
||||
// @match https://twitch.tv/*
|
||||
// @match https://twitter.com/*
|
||||
// @match https://www.twitter.com/*
|
||||
// @match https://x.com/*
|
||||
// @match https://www.x.com/*
|
||||
// @match https://www.instagram.com/*
|
||||
// @match https://instagram.com/*
|
||||
// @match https://www.facebook.com/*
|
||||
// @match https://facebook.com/*
|
||||
// @match https://fb.com/*
|
||||
// @match https://www.fb.com/*
|
||||
// @match https://www.reddit.com/*
|
||||
// @match https://reddit.com/*
|
||||
// @match https://soundcloud.com/*
|
||||
// @match https://www.soundcloud.com/*
|
||||
// @match https://www.nicovideo.jp/*
|
||||
// @match https://nicovideo.jp/*
|
||||
// @match https://kick.com/*
|
||||
// @match https://www.kick.com/*
|
||||
// @match https://bandcamp.com/*
|
||||
// @match https://*.bandcamp.com/*
|
||||
// @match https://www.mixcloud.com/*
|
||||
// @match https://mixcloud.com/*
|
||||
// @grant GM_addStyle
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));};
|
||||
|
||||
const styleCss = '.vidbee-download-container{position:fixed;bottom:16px;right:16px;z-index:9999;transition:all .2s ease;overflow:visible}.vidbee-download-container.vidbee-hidden{opacity:0;pointer-events:none;transform:scale(0)}.vidbee-download-button{position:relative;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;background:#00000080;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fffc;border:1px solid rgba(255,255,255,.1);border-radius:50%;box-shadow:0 2px 8px #0003;cursor:pointer;font-size:0;transition:all .2s ease;opacity:.6;overflow:visible}.vidbee-download-button:hover{opacity:1;background:#000000b3;border-color:#fff3;box-shadow:0 4px 12px #0000004d;transform:scale(1.1)}.vidbee-download-button:active{transform:scale(.95)}.vidbee-download-button svg{flex-shrink:0;stroke:currentColor;width:16px;height:16px}.vidbee-tooltip{position:absolute;right:calc(100% + 8px);top:50%;padding:6px 10px;background:#000000e6;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:500;white-space:nowrap;border-radius:4px;box-shadow:0 2px 8px #0000004d;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(-50%) translate(4px);z-index:10000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1}.vidbee-tooltip:after{content:"";position:absolute;left:100%;top:50%;transform:translateY(-50%);border:4px solid transparent;border-left-color:#000000e6}.vidbee-download-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}.vidbee-close-button{position:absolute;top:-6px;right:-6px;display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;background:#ff4d4de6;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;box-shadow:0 2px 4px #0000004d;cursor:pointer;font-size:0;transition:all .2s ease;z-index:1;opacity:0;pointer-events:none;overflow:visible}.vidbee-download-container:hover .vidbee-close-button{opacity:1;pointer-events:auto}.vidbee-close-button:hover{background:#ff4d4d;transform:scale(1.15);box-shadow:0 3px 6px #0006}.vidbee-close-button:active{transform:scale(.9)}.vidbee-close-button svg{flex-shrink:0;stroke:currentColor;width:10px;height:10px}.vidbee-close-button .vidbee-tooltip{right:calc(100% + 6px);top:50%;left:auto;transform:translateY(-50%) translate(4px)}.vidbee-close-button .vidbee-tooltip:after{left:100%;top:50%;right:auto;transform:translateY(-50%);border-left-color:#000000e6;border-top-color:transparent}.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}';
|
||||
importCSS(styleCss);
|
||||
function getVideoUrl() {
|
||||
return window.location.href;
|
||||
}
|
||||
function hideButtonTemporarily() {
|
||||
const container = document.getElementById("vidbee-download-btn");
|
||||
if (container) {
|
||||
container.classList.add("vidbee-hidden");
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove("vidbee-hidden");
|
||||
}
|
||||
}, 5e3);
|
||||
}
|
||||
}
|
||||
function createVidBeeButton() {
|
||||
if (document.getElementById("vidbee-download-btn")) {
|
||||
return;
|
||||
}
|
||||
const videoUrl = getVideoUrl();
|
||||
if (!videoUrl) {
|
||||
return;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.id = "vidbee-download-btn";
|
||||
container.className = "vidbee-download-container";
|
||||
const button = document.createElement("button");
|
||||
button.className = "vidbee-download-button";
|
||||
button.setAttribute("aria-label", "Download with VidBee");
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`;
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.className = "vidbee-close-button";
|
||||
closeButton.setAttribute("aria-label", "Hide button");
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`;
|
||||
closeButton.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
hideButtonTemporarily();
|
||||
});
|
||||
let clickTimer = null;
|
||||
let clickCount = 0;
|
||||
button.addEventListener("click", () => {
|
||||
clickCount++;
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`;
|
||||
window.location.href = vidbeeUrl;
|
||||
clickCount = 0;
|
||||
}, 300);
|
||||
} else if (clickCount === 2) {
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer);
|
||||
}
|
||||
clickCount = 0;
|
||||
hideButtonTemporarily();
|
||||
}
|
||||
});
|
||||
container.appendChild(button);
|
||||
container.appendChild(closeButton);
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
function init() {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createVidBeeButton);
|
||||
} else {
|
||||
createVidBeeButton();
|
||||
}
|
||||
let lastUrl = location.href;
|
||||
let urlCheckTimer = null;
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href;
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl;
|
||||
const oldButton = document.getElementById("vidbee-download-btn");
|
||||
if (oldButton) {
|
||||
oldButton.remove();
|
||||
}
|
||||
const hostname = window.location.hostname;
|
||||
const delay = hostname.includes("bilibili.com") ? 800 : 500;
|
||||
setTimeout(createVidBeeButton, delay);
|
||||
}
|
||||
};
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer);
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener("popstate", () => {
|
||||
setTimeout(checkUrlChange, 300);
|
||||
});
|
||||
}
|
||||
init();
|
||||
|
||||
})();
|
||||
16
monkey/package.json
Normal file
16
monkey/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "vidbee-quick-download",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3",
|
||||
"vite-plugin-monkey": "^7.1.1"
|
||||
}
|
||||
}
|
||||
943
monkey/pnpm-lock.yaml
generated
Normal file
943
monkey/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,943 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.1.3
|
||||
version: 7.3.0
|
||||
vite-plugin-monkey:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.8(postcss@8.5.6)(vite@7.3.0)
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cuint@0.2.2:
|
||||
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.4.0:
|
||||
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
import-meta-resolve@4.2.0:
|
||||
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-wsl@3.1.0:
|
||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@3.1.0:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
mime@2.5.2:
|
||||
resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimatch@3.0.8:
|
||||
resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss-url@10.1.3:
|
||||
resolution: {integrity: sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rollup@4.54.0:
|
||||
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
systemjs@6.15.1:
|
||||
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite-plugin-monkey@7.1.8:
|
||||
resolution: {integrity: sha512-FGz1jlHcodt+L120UcHLiN0jXqQZdZHxLuHcGQg8Zjas6BulxLM1oiGUg3M95HbjYVFH6ygrOJoZ5YCYu1purw==}
|
||||
peerDependencies:
|
||||
vite: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cuint@0.2.2: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.4.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
'@esbuild/android-arm': 0.27.2
|
||||
'@esbuild/android-arm64': 0.27.2
|
||||
'@esbuild/android-x64': 0.27.2
|
||||
'@esbuild/darwin-arm64': 0.27.2
|
||||
'@esbuild/darwin-x64': 0.27.2
|
||||
'@esbuild/freebsd-arm64': 0.27.2
|
||||
'@esbuild/freebsd-x64': 0.27.2
|
||||
'@esbuild/linux-arm': 0.27.2
|
||||
'@esbuild/linux-arm64': 0.27.2
|
||||
'@esbuild/linux-ia32': 0.27.2
|
||||
'@esbuild/linux-loong64': 0.27.2
|
||||
'@esbuild/linux-mips64el': 0.27.2
|
||||
'@esbuild/linux-ppc64': 0.27.2
|
||||
'@esbuild/linux-riscv64': 0.27.2
|
||||
'@esbuild/linux-s390x': 0.27.2
|
||||
'@esbuild/linux-x64': 0.27.2
|
||||
'@esbuild/netbsd-arm64': 0.27.2
|
||||
'@esbuild/netbsd-x64': 0.27.2
|
||||
'@esbuild/openbsd-arm64': 0.27.2
|
||||
'@esbuild/openbsd-x64': 0.27.2
|
||||
'@esbuild/openharmony-arm64': 0.27.2
|
||||
'@esbuild/sunos-x64': 0.27.2
|
||||
'@esbuild/win32-arm64': 0.27.2
|
||||
'@esbuild/win32-ia32': 0.27.2
|
||||
'@esbuild/win32-x64': 0.27.2
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
import-meta-resolve@4.2.0: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-wsl@3.1.0:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
|
||||
mime@2.5.2: {}
|
||||
|
||||
minimatch@3.0.8:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.4.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
postcss-url@10.1.3(postcss@8.5.6):
|
||||
dependencies:
|
||||
make-dir: 3.1.0
|
||||
mime: 2.5.2
|
||||
minimatch: 3.0.8
|
||||
postcss: 8.5.6
|
||||
xxhashjs: 0.2.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rollup@4.54.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.54.0
|
||||
'@rollup/rollup-android-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-x64': 4.54.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.54.0
|
||||
'@rollup/rollup-freebsd-x64': 4.54.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.54.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.54.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.54.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
systemjs@6.15.1: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-plugin-monkey@7.1.8(postcss@8.5.6)(vite@7.3.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
cross-spawn: 7.0.6
|
||||
htmlparser2: 10.0.0
|
||||
import-meta-resolve: 4.2.0
|
||||
magic-string: 0.30.21
|
||||
mrmime: 2.0.1
|
||||
open: 10.2.0
|
||||
picocolors: 1.1.1
|
||||
postcss-url: 10.1.3(postcss@8.5.6)
|
||||
systemjs: 6.15.1
|
||||
optionalDependencies:
|
||||
vite: 7.3.0
|
||||
transitivePeerDependencies:
|
||||
- postcss
|
||||
|
||||
vite@7.3.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.54.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
dependencies:
|
||||
cuint: 0.2.2
|
||||
146
monkey/src/main.ts
Normal file
146
monkey/src/main.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import './style.css'
|
||||
|
||||
// Get current video URL
|
||||
// yt-dlp can handle all URLs directly, so we just return the current URL
|
||||
function getVideoUrl(): string | null {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
// Temporarily hide button
|
||||
function hideButtonTemporarily(): void {
|
||||
const container = document.getElementById('vidbee-download-btn')
|
||||
if (container) {
|
||||
container.classList.add('vidbee-hidden')
|
||||
// Auto restore after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove('vidbee-hidden')
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create VidBee download button
|
||||
function createVidBeeButton(): void {
|
||||
// Check if button already exists
|
||||
if (document.getElementById('vidbee-download-btn')) {
|
||||
return
|
||||
}
|
||||
|
||||
const videoUrl = getVideoUrl()
|
||||
if (!videoUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create button container
|
||||
const container = document.createElement('div')
|
||||
container.id = 'vidbee-download-btn'
|
||||
container.className = 'vidbee-download-container'
|
||||
|
||||
// Create main download button
|
||||
const button = document.createElement('button')
|
||||
button.className = 'vidbee-download-button'
|
||||
button.setAttribute('aria-label', 'Download with VidBee')
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.className = 'vidbee-close-button'
|
||||
closeButton.setAttribute('aria-label', 'Hide button')
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`
|
||||
|
||||
// Handle close button click - temporarily hide
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
hideButtonTemporarily()
|
||||
})
|
||||
|
||||
let clickTimer: number | null = null
|
||||
let clickCount = 0
|
||||
|
||||
// Handle main button click event - single click for download
|
||||
button.addEventListener('click', () => {
|
||||
clickCount++
|
||||
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
// Single click - trigger download
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
clickCount = 0
|
||||
}, 300)
|
||||
} else if (clickCount === 2) {
|
||||
// Double click - temporarily hide
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer)
|
||||
}
|
||||
clickCount = 0
|
||||
hideButtonTemporarily()
|
||||
}
|
||||
})
|
||||
|
||||
// Assemble container
|
||||
container.appendChild(button)
|
||||
container.appendChild(closeButton)
|
||||
|
||||
// Insert container directly to body (fixed position)
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
function init(): void {
|
||||
// Wait for page to fully load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createVidBeeButton)
|
||||
} else {
|
||||
createVidBeeButton()
|
||||
}
|
||||
|
||||
// Handle SPA navigation (when navigating between videos on sites like YouTube, Bilibili, etc.)
|
||||
let lastUrl = location.href
|
||||
let urlCheckTimer: number | null = null
|
||||
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl
|
||||
// Remove old button and create new one
|
||||
const oldButton = document.getElementById('vidbee-download-btn')
|
||||
if (oldButton) {
|
||||
oldButton.remove()
|
||||
}
|
||||
// Wait a bit for the page to update (different sites have different update speeds)
|
||||
const hostname = window.location.hostname
|
||||
const delay = hostname.includes('bilibili.com') ? 800 : 500
|
||||
setTimeout(createVidBeeButton, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Use MutationObserver for DOM changes (works for most SPA sites)
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer)
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100)
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
// Also listen to popstate for browser navigation
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(checkUrlChange, 300)
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
169
monkey/src/style.css
Normal file
169
monkey/src/style.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* VidBee Download Button Container */
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hidden state */
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Main Download Button */
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
/* Close Button - Hidden by default */
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Show close button on container hover */
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Close button tooltip positioning */
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
4
monkey/src/vite-env.d.ts
vendored
Normal file
4
monkey/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-monkey/client" />
|
||||
//// <reference types="vite-plugin-monkey/global" />
|
||||
/// <reference types="vite-plugin-monkey/style" />
|
||||
24
monkey/tsconfig.json
Normal file
24
monkey/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
monkey/tsconfig.node.json
Normal file
13
monkey/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
67
monkey/vite.config.ts
Normal file
67
monkey/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import monkey from 'vite-plugin-monkey'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
monkey({
|
||||
entry: 'src/main.ts',
|
||||
userscript: {
|
||||
icon: 'https://vidbee.org/favicon.svg',
|
||||
namespace: 'vidbee',
|
||||
match: [
|
||||
// YouTube
|
||||
'https://www.youtube.com/*',
|
||||
'https://youtube.com/*',
|
||||
'https://music.youtube.com/*',
|
||||
// Bilibili (哔哩哔哩)
|
||||
'https://www.bilibili.com/*',
|
||||
'https://bilibili.com/*',
|
||||
// TikTok
|
||||
'https://www.tiktok.com/*',
|
||||
'https://tiktok.com/*',
|
||||
// Vimeo
|
||||
'https://vimeo.com/*',
|
||||
'https://www.vimeo.com/*',
|
||||
// Dailymotion
|
||||
'https://www.dailymotion.com/*',
|
||||
'https://dailymotion.com/*',
|
||||
// Twitch
|
||||
'https://www.twitch.tv/*',
|
||||
'https://twitch.tv/*',
|
||||
// Twitter/X
|
||||
'https://twitter.com/*',
|
||||
'https://www.twitter.com/*',
|
||||
'https://x.com/*',
|
||||
'https://www.x.com/*',
|
||||
// Instagram
|
||||
'https://www.instagram.com/*',
|
||||
'https://instagram.com/*',
|
||||
// Facebook
|
||||
'https://www.facebook.com/*',
|
||||
'https://facebook.com/*',
|
||||
'https://fb.com/*',
|
||||
'https://www.fb.com/*',
|
||||
// Reddit
|
||||
'https://www.reddit.com/*',
|
||||
'https://reddit.com/*',
|
||||
// SoundCloud
|
||||
'https://soundcloud.com/*',
|
||||
'https://www.soundcloud.com/*',
|
||||
// NicoNico
|
||||
'https://www.nicovideo.jp/*',
|
||||
'https://nicovideo.jp/*',
|
||||
// Kick
|
||||
'https://kick.com/*',
|
||||
'https://www.kick.com/*',
|
||||
// Bandcamp
|
||||
'https://bandcamp.com/*',
|
||||
'https://*.bandcamp.com/*',
|
||||
// Mixcloud
|
||||
'https://www.mixcloud.com/*',
|
||||
'https://mixcloud.com/*'
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.1.8",
|
||||
"version": "1.1.1",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
@@ -12,13 +12,15 @@
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
|
||||
"build": "pnpm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac",
|
||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "pnpm run check && bumpp"
|
||||
"build": "electron-vite build",
|
||||
"setup": "node scripts/setup-dev-binaries.js",
|
||||
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
|
||||
"build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
||||
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -26,8 +28,10 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@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-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -40,13 +44,16 @@
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/plugin-jsx": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"electron-ipc-decorator": "^0.2.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.5.3",
|
||||
"jotai": "^2.15.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
@@ -54,9 +61,11 @@
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router": "^7.9.4",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yt-dlp-wrap-plus": "^2.3.20",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
@@ -69,6 +78,7 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"bumpp": "^10.3.1",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"electron": "^38.1.2",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
652
pnpm-lock.yaml
generated
652
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
resources/.gitignore
vendored
7
resources/.gitignore
vendored
@@ -2,8 +2,13 @@
|
||||
yt-dlp.exe
|
||||
yt-dlp_macos
|
||||
yt-dlp_linux
|
||||
ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
deno.exe
|
||||
deno
|
||||
|
||||
# But keep the README
|
||||
!README.md
|
||||
!.gitignore
|
||||
|
||||
|
||||
@@ -48,8 +48,44 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
|
||||
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
|
||||
```
|
||||
|
||||
## ffmpeg Binaries
|
||||
|
||||
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
|
||||
|
||||
### Required Files
|
||||
|
||||
1. **Windows**: `ffmpeg.exe`
|
||||
2. **macOS**: `ffmpeg_macos`
|
||||
3. **Linux**: `ffmpeg_linux`
|
||||
|
||||
### How to Download
|
||||
|
||||
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
|
||||
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
|
||||
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
|
||||
|
||||
### Note
|
||||
|
||||
- If you don't place binaries here, the app will attempt to download them at runtime
|
||||
- The app will automatically use the bundled version if available
|
||||
- File sizes: ~10-15 MB per binary
|
||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
|
||||
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
|
||||
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
|
||||
|
||||
## JS Runtime (Deno)
|
||||
|
||||
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
|
||||
|
||||
### Required Files
|
||||
|
||||
1. **Windows**: `deno.exe`
|
||||
2. **macOS**: `deno`
|
||||
3. **Linux**: `deno`
|
||||
|
||||
### How to Download
|
||||
|
||||
- Visit: <https://github.com/denoland/deno/releases/latest>
|
||||
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
|
||||
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
|
||||
|
||||
### Note
|
||||
|
||||
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.
|
||||
|
||||
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE `download_history` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`thumbnail` text,
|
||||
`type` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`download_path` text,
|
||||
`saved_file_name` text,
|
||||
`file_size` integer,
|
||||
`duration` integer,
|
||||
`downloaded_at` integer NOT NULL,
|
||||
`completed_at` integer,
|
||||
`sort_key` integer NOT NULL,
|
||||
`error` text,
|
||||
`description` text,
|
||||
`channel` text,
|
||||
`uploader` text,
|
||||
`view_count` integer,
|
||||
`tags` text,
|
||||
`origin` text,
|
||||
`subscription_id` text,
|
||||
`selected_format` text,
|
||||
`playlist_id` text,
|
||||
`playlist_title` text,
|
||||
`playlist_index` integer,
|
||||
`playlist_size` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `subscription_items` (
|
||||
`subscription_id` text NOT NULL,
|
||||
`item_id` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`published_at` integer NOT NULL,
|
||||
`thumbnail` text,
|
||||
`added` integer NOT NULL,
|
||||
`download_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
PRIMARY KEY(`subscription_id`, `item_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `subscription_items_subscription_idx` ON `subscription_items` (`subscription_id`);--> statement-breakpoint
|
||||
CREATE TABLE `subscriptions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`source_url` text NOT NULL,
|
||||
`feed_url` text NOT NULL,
|
||||
`platform` text NOT NULL,
|
||||
`keywords` text NOT NULL,
|
||||
`tags` text NOT NULL,
|
||||
`only_latest` integer NOT NULL,
|
||||
`enabled` integer NOT NULL,
|
||||
`cover_url` text,
|
||||
`latest_video_title` text,
|
||||
`latest_video_published_at` integer,
|
||||
`last_checked_at` integer,
|
||||
`last_success_at` integer,
|
||||
`status` text NOT NULL,
|
||||
`last_error` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`download_directory` text,
|
||||
`naming_template` text
|
||||
);
|
||||
451
resources/drizzle/meta/0000_snapshot.json
Normal file
451
resources/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "91501763-7554-435d-91d8-382c52ee65e4",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"download_history": {
|
||||
"name": "download_history",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail": {
|
||||
"name": "thumbnail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"download_path": {
|
||||
"name": "download_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"saved_file_name": {
|
||||
"name": "saved_file_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_key": {
|
||||
"name": "sort_key",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"channel": {
|
||||
"name": "channel",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uploader": {
|
||||
"name": "uploader",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"view_count": {
|
||||
"name": "view_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subscription_id": {
|
||||
"name": "subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"selected_format": {
|
||||
"name": "selected_format",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"playlist_id": {
|
||||
"name": "playlist_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"playlist_title": {
|
||||
"name": "playlist_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"playlist_index": {
|
||||
"name": "playlist_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"playlist_size": {
|
||||
"name": "playlist_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"subscription_items": {
|
||||
"name": "subscription_items",
|
||||
"columns": {
|
||||
"subscription_id": {
|
||||
"name": "subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail": {
|
||||
"name": "thumbnail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"added": {
|
||||
"name": "added",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"download_id": {
|
||||
"name": "download_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"subscription_items_subscription_idx": {
|
||||
"name": "subscription_items_subscription_idx",
|
||||
"columns": ["subscription_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"subscription_items_pk": {
|
||||
"columns": ["subscription_id", "item_id"],
|
||||
"name": "subscription_items_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "subscriptions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"feed_url": {
|
||||
"name": "feed_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"keywords": {
|
||||
"name": "keywords",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"only_latest": {
|
||||
"name": "only_latest",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cover_url": {
|
||||
"name": "cover_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"latest_video_title": {
|
||||
"name": "latest_video_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"latest_video_published_at": {
|
||||
"name": "latest_video_published_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_checked_at": {
|
||||
"name": "last_checked_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_success_at": {
|
||||
"name": "last_success_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"download_directory": {
|
||||
"name": "download_directory",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"naming_template": {
|
||||
"name": "naming_template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
13
resources/drizzle/meta/_journal.json
Normal file
13
resources/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1763176841336,
|
||||
"tag": "0000_swift_aaron_stack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
resources/tray-icon.png
Normal file
BIN
resources/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 924 B |
BIN
screenshots/browser-script.png
Normal file
BIN
screenshots/browser-script.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
@@ -3,38 +3,6 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
/**
|
||||
* Check if yt-dlp binary exists in resources directory
|
||||
* Usage: node scripts/check-ytdlp.js [platform]
|
||||
* Platform options: win, mac, linux
|
||||
* Exit with error code 1 if not found
|
||||
*/
|
||||
function checkYtDlpExists(platform) {
|
||||
const platformMap = {
|
||||
win: 'yt-dlp.exe',
|
||||
mac: 'yt-dlp_macos',
|
||||
linux: 'yt-dlp_linux'
|
||||
}
|
||||
|
||||
const filename = platformMap[platform]
|
||||
if (!filename) {
|
||||
console.error('❌ Error: Invalid platform specified!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ytdlpPath = path.join(__dirname, '..', 'resources', filename)
|
||||
|
||||
if (!fs.existsSync(ytdlpPath)) {
|
||||
console.error(`❌ Error: resources/${filename} not found!`)
|
||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
||||
console.error('You can download it from: https://github.com/yt-dlp/yt-dlp/releases/latest')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`✅ ${filename} found in resources/ directory`)
|
||||
}
|
||||
|
||||
// Get platform from command line arguments
|
||||
const platform = process.argv[2]
|
||||
|
||||
@@ -44,5 +12,72 @@ if (!platform) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkYtDlpExists(platform)
|
||||
const supportedPlatforms = ['win', 'mac', 'linux']
|
||||
|
||||
if (!supportedPlatforms.includes(platform)) {
|
||||
console.error('❌ Error: Invalid platform specified!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const binaries = [
|
||||
{
|
||||
label: 'yt-dlp',
|
||||
filenameMap: {
|
||||
win: 'yt-dlp.exe',
|
||||
mac: 'yt-dlp_macos',
|
||||
linux: 'yt-dlp_linux'
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'ffmpeg',
|
||||
filenameMap: {
|
||||
win: 'ffmpeg.exe',
|
||||
mac: 'ffmpeg_macos',
|
||||
linux: 'ffmpeg_linux'
|
||||
},
|
||||
help: {
|
||||
win: 'https://ffmpeg.org/download.html',
|
||||
linux: 'https://ffmpeg.org/download.html',
|
||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'deno',
|
||||
filenameMap: {
|
||||
win: 'deno.exe',
|
||||
mac: 'deno',
|
||||
linux: 'deno'
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/denoland/deno/releases/latest'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
let hasMissingBinary = false
|
||||
|
||||
for (const binary of binaries) {
|
||||
const filename = binary.filenameMap[platform]
|
||||
const binaryPath = path.join(__dirname, '..', 'resources', filename)
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
console.error(`❌ Error: resources/${filename} not found!`)
|
||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
||||
const help =
|
||||
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
|
||||
if (help) {
|
||||
console.error(`See ${help}`)
|
||||
}
|
||||
hasMissingBinary = true
|
||||
} else {
|
||||
console.log(`✅ ${filename} found in resources/ directory`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMissingBinary) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
564
scripts/setup-dev-binaries.js
Executable file
564
scripts/setup-dev-binaries.js
Executable file
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Development environment setup script
|
||||
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { execSync } = require('node:child_process')
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
|
||||
// Configuration
|
||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||
const GITHUB_TOKEN =
|
||||
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||
|
||||
// Platform configuration
|
||||
const PLATFORM_CONFIG = {
|
||||
win32: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp.exe',
|
||||
output: 'yt-dlp.exe'
|
||||
},
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
||||
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||
output: 'ffmpeg.exe',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
}
|
||||
},
|
||||
darwin: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp_macos',
|
||||
output: 'yt-dlp_macos'
|
||||
},
|
||||
ffmpeg: {
|
||||
// For development, download only the architecture matching current system
|
||||
arm64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-arm64.*\.zip$/i
|
||||
}
|
||||
},
|
||||
x64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-x86_64.*\.zip$/i
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
linux: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp',
|
||||
output: 'yt-dlp_linux'
|
||||
},
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
||||
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||
output: 'ffmpeg_linux',
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function log(message, type = 'info') {
|
||||
const icons = {
|
||||
info: '📦',
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warn: '⚠️',
|
||||
download: '⬇️'
|
||||
}
|
||||
console.log(`${icons[type] || 'ℹ️'} ${message}`)
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
|
||||
protocol
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const headers = {
|
||||
'User-Agent': 'vidbee-setup',
|
||||
Accept: 'application/vnd.github+json'
|
||||
}
|
||||
if (GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
}
|
||||
|
||||
protocol
|
||||
.get(url, { headers }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
return fetchJson(response.headers.location).then(resolve).catch(reject)
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
let body = ''
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inferFfmpegInnerPath(assetName, binaryName) {
|
||||
if (!assetName) {
|
||||
return null
|
||||
}
|
||||
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return `${match[1]}/bin/${binaryName}`
|
||||
}
|
||||
|
||||
async function resolveReleaseAsset(release) {
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
|
||||
if (repoCandidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let lastError
|
||||
for (const repo of repoCandidates) {
|
||||
try {
|
||||
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
|
||||
const assets = Array.isArray(data.assets) ? data.assets : []
|
||||
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
|
||||
if (match?.browser_download_url) {
|
||||
return { name: match.name, url: match.browser_download_url }
|
||||
}
|
||||
lastError = new Error(`No matching assets found in ${repo}`)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractZip(zipPath, extractDir) {
|
||||
const platform = os.platform()
|
||||
ensureDir(extractDir)
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Use PowerShell Expand-Archive on Windows
|
||||
try {
|
||||
const zipAbsPath = path.resolve(zipPath)
|
||||
const extractAbsDir = path.resolve(extractDir)
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to extract zip: ${error.message}`)
|
||||
}
|
||||
} else {
|
||||
// Use unzip command on macOS/Linux
|
||||
try {
|
||||
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to extract zip: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractTarXz(tarPath, extractDir) {
|
||||
ensureDir(extractDir)
|
||||
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
function setExecutable(filePath) {
|
||||
if (os.platform() !== 'win32') {
|
||||
fs.chmodSync(filePath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
function getDenoAssetName(platform, arch) {
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-pc-windows-msvc.zip'
|
||||
}
|
||||
return 'deno-x86_64-pc-windows-msvc.zip'
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-apple-darwin.zip'
|
||||
}
|
||||
return 'deno-x86_64-apple-darwin.zip'
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-unknown-linux-gnu.zip'
|
||||
}
|
||||
return 'deno-x86_64-unknown-linux-gnu.zip'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDenoOutputName(platform) {
|
||||
return platform === 'win32' ? 'deno.exe' : 'deno'
|
||||
}
|
||||
|
||||
// Main download functions
|
||||
async function downloadYtDlp(config) {
|
||||
const { asset, output } = config.ytdlp
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ${asset}...`, 'download')
|
||||
const url = `${YTDLP_BASE_URL}/${asset}`
|
||||
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempPath)
|
||||
fs.renameSync(tempPath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegWindows(config) {
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegMac(config) {
|
||||
const arch = os.arch()
|
||||
const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64']
|
||||
|
||||
if (!ffmpegConfig) {
|
||||
throw new Error(`Unsupported architecture: ${arch}`)
|
||||
}
|
||||
|
||||
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegLinux(config) {
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempTar)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDenoRuntime() {
|
||||
const platform = os.platform()
|
||||
const arch = os.arch()
|
||||
const assetName = getDenoAssetName(platform, arch)
|
||||
|
||||
if (!assetName) {
|
||||
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
|
||||
return
|
||||
}
|
||||
|
||||
const outputName = getDenoOutputName(platform)
|
||||
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${outputName} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
|
||||
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting Deno runtime...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, outputName)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`Deno binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${outputName} successfully`, 'success')
|
||||
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Main setup function
|
||||
async function setup() {
|
||||
const platform = os.platform()
|
||||
const config = PLATFORM_CONFIG[platform]
|
||||
|
||||
if (!config) {
|
||||
log(`Unsupported platform: ${platform}`, 'error')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log(`Setting up development binaries for ${platform}...`, 'info')
|
||||
ensureDir(RESOURCES_DIR)
|
||||
|
||||
try {
|
||||
// Download yt-dlp
|
||||
await downloadYtDlp(config)
|
||||
|
||||
// Download JS runtime (Deno)
|
||||
await downloadDenoRuntime()
|
||||
|
||||
// Download ffmpeg
|
||||
if (platform === 'win32') {
|
||||
await downloadFfmpegWindows(config)
|
||||
} else if (platform === 'darwin') {
|
||||
await downloadFfmpegMac(config)
|
||||
} else if (platform === 'linux') {
|
||||
await downloadFfmpegLinux(config)
|
||||
}
|
||||
|
||||
log('Development environment setup completed!', 'success')
|
||||
} catch (error) {
|
||||
log(`Setup failed: ${error.message}`, 'error')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run setup
|
||||
if (require.main === module) {
|
||||
setup()
|
||||
}
|
||||
|
||||
module.exports = { setup }
|
||||
@@ -5,15 +5,6 @@ import log from 'electron-log/main'
|
||||
* Set log format, file path, transport methods, etc.
|
||||
*/
|
||||
export function configureLogger() {
|
||||
// Configure console output format - support colors and scope, time in gray
|
||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
||||
|
||||
// Enable console colors
|
||||
log.transports.console.useStyles = true
|
||||
|
||||
// Configure file output format - include scope information
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
||||
|
||||
// Set log levels
|
||||
// Development: show all logs
|
||||
// Production: show info level and above only
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
if (!trimmed) {
|
||||
return '%(title)s via VidBee.%(ext)s'
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/')
|
||||
const safeParts = normalized
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '' && part !== '.' && part !== '..')
|
||||
.map((part) => part.replace(/[<>:"|?*]/g, '-').replace(/[. ]+$/g, ''))
|
||||
.filter((part) => part !== '')
|
||||
return safeParts.length === 0 ? '%(title)s via VidBee.%(ext)s' : safeParts.join('/')
|
||||
}
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
@@ -14,7 +30,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
|
||||
return 'bestvideo+none'
|
||||
}
|
||||
if (!audioFormat || audioFormat === 'best') {
|
||||
return 'best'
|
||||
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
|
||||
return 'bestvideo+bestaudio'
|
||||
}
|
||||
return `bestvideo+${audioFormat}`
|
||||
}
|
||||
@@ -44,7 +61,8 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
||||
export const buildDownloadArgs = (
|
||||
options: DownloadOptions,
|
||||
downloadPath: string,
|
||||
settings: AppSettings
|
||||
settings: AppSettings,
|
||||
jsRuntimeArgs: string[] = []
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
|
||||
@@ -53,7 +71,11 @@ export const buildDownloadArgs = (
|
||||
|
||||
// Format selection
|
||||
if (options.type === 'video') {
|
||||
args.push('-f', resolveVideoFormatSelector(options))
|
||||
const formatSelector = resolveVideoFormatSelector(options)
|
||||
args.push('-f', formatSelector)
|
||||
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
|
||||
// based on codec compatibility. Forcing MP4 can cause failures
|
||||
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
|
||||
} else if (options.type === 'audio') {
|
||||
args.push('-f', resolveAudioFormatSelector(options))
|
||||
} else if (options.type === 'extract') {
|
||||
@@ -75,7 +97,12 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
|
||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||
const filenameTemplate = sanitizeFilenameTemplate(
|
||||
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
|
||||
)
|
||||
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
|
||||
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
@@ -90,12 +117,22 @@ export const buildDownloadArgs = (
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', settings.configPath)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
if (jsRuntimeArgs.length > 0) {
|
||||
args.push(...jsRuntimeArgs)
|
||||
}
|
||||
|
||||
args.push(options.url)
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
} from '../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
@@ -15,7 +14,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
@@ -187,7 +185,7 @@ export const resolveSelectedFormat = (
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const preset = settings.oneClickQuality ?? 'auto'
|
||||
const preset = settings.oneClickQuality ?? 'best'
|
||||
|
||||
if (options.type === 'video') {
|
||||
const videoFormats = formats.filter(
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||
import { subscriptionManager } from './lib/subscription-manager'
|
||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyAutoLaunchSetting } from './utils/auto-launch'
|
||||
import { applyDockVisibility } from './utils/dock'
|
||||
|
||||
// Initialize electron-log for main process
|
||||
log.initialize()
|
||||
@@ -17,21 +24,119 @@ log.initialize()
|
||||
// Configure logger settings
|
||||
configureLogger()
|
||||
|
||||
const RENDERER_DIST_PATH = join(__dirname, '../renderer')
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
interface DeepLinkData {
|
||||
url: string
|
||||
type: 'single' | 'playlist'
|
||||
}
|
||||
const pendingDeepLinkUrls: DeepLinkData[] = []
|
||||
let isRendererReady = false
|
||||
|
||||
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
|
||||
try {
|
||||
const parsed = new URL(rawUrl)
|
||||
if (parsed.protocol !== `${APP_PROTOCOL}:`) {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = parsed.hostname
|
||||
const path = parsed.pathname.replace(/^\/+/, '')
|
||||
const isDownloadLink = host === 'download' || path.startsWith('download')
|
||||
if (!isDownloadLink) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetUrl = parsed.searchParams.get('url')
|
||||
if (!targetUrl || !targetUrl.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typeParam = parsed.searchParams.get('type')
|
||||
const type = typeParam === 'playlist' ? 'playlist' : 'single'
|
||||
|
||||
return {
|
||||
url: targetUrl.trim(),
|
||||
type
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to parse deep link:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const deliverDeepLink = (data: DeepLinkData): void => {
|
||||
if (!mainWindow || !isRendererReady) {
|
||||
pendingDeepLinkUrls.push(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('download:deeplink', data)
|
||||
}
|
||||
|
||||
const flushPendingDeepLinks = (): void => {
|
||||
if (!mainWindow || !isRendererReady || pendingDeepLinkUrls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length)
|
||||
for (const data of pending) {
|
||||
mainWindow.webContents.send('download:deeplink', data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeepLinkUrl = (rawUrl: string): void => {
|
||||
const data = parseDownloadDeepLink(rawUrl)
|
||||
if (!data) {
|
||||
log.warn('Ignored unsupported deep link:', rawUrl)
|
||||
return
|
||||
}
|
||||
deliverDeepLink(data)
|
||||
}
|
||||
|
||||
const handleDeepLinkArgv = (argv: string[]): void => {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith(`${APP_PROTOCOL}://`)) {
|
||||
handleDeepLinkUrl(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
// Create the browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
titleBarStyle: 'hidden', // Hide title bar on macOS
|
||||
autoHideMenuBar: true,
|
||||
icon: appIcon, // Set application icon
|
||||
frame: false,
|
||||
vibrancy: 'fullscreen-ui', // on MacOS
|
||||
backgroundMaterial: 'acrylic', // on Windows 11
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -39,7 +144,20 @@ export function createWindow(): void {
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // Allow drag regions to work
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (isMac) {
|
||||
windowOptions.titleBarStyle = 'hidden'
|
||||
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
|
||||
windowOptions.vibrancy = 'fullscreen-ui'
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
windowOptions.backgroundMaterial = 'acrylic'
|
||||
}
|
||||
|
||||
// Create the browser window
|
||||
mainWindow = new BrowserWindow(windowOptions)
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
const closeToTray = settingsManager.get('closeToTray')
|
||||
@@ -63,9 +181,15 @@ export function createWindow(): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||
isRendererReady = true
|
||||
flushPendingDeepLinks()
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -92,14 +216,69 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('Skipping auto-updater initialization in development mode')
|
||||
return
|
||||
function sanitizeRequestPath(requestUrl: URL): string {
|
||||
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
|
||||
const trimmedLeading = rawPath.replace(/^\/+/, '')
|
||||
const cleaned = trimmedLeading.replace(/\/+$/, '')
|
||||
return cleaned || 'index.html'
|
||||
}
|
||||
|
||||
function isWithinBase(targetPath: string, basePath: string): boolean {
|
||||
const relativePath = relative(basePath, targetPath)
|
||||
return !relativePath.startsWith('..') && !isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
function resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {
|
||||
const sanitizedPath = sanitizeRequestPath(requestUrl)
|
||||
const [rootSegment, ...restSegments] = sanitizedPath.split('/')
|
||||
const rendererPath = restSegments.join('/') || 'index.html'
|
||||
|
||||
if (rootSegment === 'renderer') {
|
||||
const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)
|
||||
|
||||
if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {
|
||||
return rendererTarget
|
||||
}
|
||||
}
|
||||
|
||||
const userDataTarget = resolve(userDataPath, sanitizedPath)
|
||||
|
||||
if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {
|
||||
return userDataTarget
|
||||
}
|
||||
|
||||
const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)
|
||||
|
||||
if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {
|
||||
return rendererFallback
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function registerVidbeeProtocol(): void {
|
||||
try {
|
||||
console.log('Initializing auto-updater...')
|
||||
const userDataPath = app.getPath('userData')
|
||||
protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)
|
||||
|
||||
if (!filePath) {
|
||||
log.error(`File not found for ${request.url}`)
|
||||
callback({ error: -6 })
|
||||
return
|
||||
}
|
||||
|
||||
callback(filePath)
|
||||
})
|
||||
} catch (error) {
|
||||
log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
try {
|
||||
log.info('Initializing auto-updater...')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
autoUpdater.logger = log
|
||||
@@ -108,56 +287,80 @@ function initAutoUpdater(): void {
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
console.log('Update available:', info.version)
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
|
||||
// If auto-update is enabled, the update will be downloaded automatically
|
||||
// because autoDownload is set to true
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, update will be downloaded automatically')
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log.info('Update not available:', info.version)
|
||||
console.log('Update not available:', info.version)
|
||||
mainWindow?.webContents.send('update:not-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Update error:', err)
|
||||
console.error('Update error:', err)
|
||||
mainWindow?.webContents.send('update:error', err.message)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
log.info('Download progress:', progressObj.percent)
|
||||
console.log('Download progress:', progressObj.percent)
|
||||
mainWindow?.webContents.send('update:download-progress', progressObj)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
console.log('Update downloaded:', info.version)
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('update:show-notification', {
|
||||
title: 'Update Ready',
|
||||
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
|
||||
icon: 'app-icon'
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, checking for updates...')
|
||||
console.log('Auto-update is enabled, checking for updates...')
|
||||
void autoUpdater.checkForUpdatesAndNotify()
|
||||
}
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
console.log('Auto-updater initialized successfully')
|
||||
|
||||
// Check for updates immediately if auto-update is enabled
|
||||
const autoUpdateEnabled = settingsManager.get('autoUpdate')
|
||||
if (autoUpdateEnabled) {
|
||||
log.info('Auto-update is enabled, checking for updates immediately...')
|
||||
// Use checkForUpdates instead of checkForUpdatesAndNotify
|
||||
// because we have our own notification system and want to ensure immediate download
|
||||
void autoUpdater.checkForUpdates()
|
||||
} else {
|
||||
log.info('Auto-update is disabled, skipping automatic update check')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize auto-updater:', error)
|
||||
console.error('Failed to initialize auto-updater:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
handleDeepLinkArgv(argv)
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleDeepLinkUrl(url)
|
||||
})
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -165,6 +368,13 @@ app.whenReady().then(async () => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.vidbee')
|
||||
|
||||
registerVidbeeProtocol()
|
||||
|
||||
const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)
|
||||
if (!registered) {
|
||||
log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)
|
||||
}
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
@@ -174,6 +384,15 @@ app.whenReady().then(async () => {
|
||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
||||
log.info('IPC services available:', Object.keys(services))
|
||||
|
||||
// Initialize ffmpeg
|
||||
try {
|
||||
log.info('Initializing ffmpeg...')
|
||||
await ffmpegManager.initialize()
|
||||
log.info('ffmpeg initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize ffmpeg:', error)
|
||||
}
|
||||
|
||||
// Initialize yt-dlp
|
||||
try {
|
||||
log.info('Initializing yt-dlp...')
|
||||
@@ -183,6 +402,9 @@ app.whenReady().then(async () => {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
createWindow()
|
||||
|
||||
initAutoUpdater()
|
||||
@@ -190,6 +412,10 @@ app.whenReady().then(async () => {
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
handleDeepLinkArgv(process.argv)
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DownloadService } from './services/download-service'
|
||||
import { FileSystemService } from './services/file-system-service'
|
||||
import { HistoryService } from './services/history-service'
|
||||
import { SettingsService } from './services/settings-service'
|
||||
import { SubscriptionService } from './services/subscription-service'
|
||||
import { ThumbnailService } from './services/thumbnail-service'
|
||||
import { UpdateService } from './services/update-service'
|
||||
import { WindowService } from './services/window-service'
|
||||
@@ -15,6 +16,7 @@ export const services = createServices([
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
SettingsService,
|
||||
SubscriptionService,
|
||||
ThumbnailService,
|
||||
UpdateService,
|
||||
WindowService
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os from 'node:os'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { scopedLoggers } from '../../utils/logger'
|
||||
|
||||
class AppService extends IpcService {
|
||||
static readonly groupName = 'app'
|
||||
@@ -32,6 +33,26 @@ class AppService extends IpcService {
|
||||
|
||||
return dialog.showMessageBox(options)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getSiteIcon(_context: IpcContext, domain: string): Promise<string | null> {
|
||||
try {
|
||||
const iconUrl = `https://unavatar.io/${domain}`
|
||||
const response = await fetch(iconUrl)
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const contentType = response.headers.get('content-type') || 'image/png'
|
||||
const base64 = buffer.toString('base64')
|
||||
return `data:${contentType};base64,${base64}`
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to fetch site icon:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AppService }
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
} from '../../../shared/types'
|
||||
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
|
||||
async startPlaylistDownload(
|
||||
_context: IpcContext,
|
||||
options: PlaylistDownloadOptions
|
||||
): Promise<string[]> {
|
||||
): Promise<PlaylistDownloadResult> {
|
||||
return downloadEngine.startPlaylistDownload(options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { execFile, execSync } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { clipboard, dialog, shell } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { scopedLoggers } from '../../utils/logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -40,7 +41,23 @@ class FileSystemService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
getDefaultDownloadPath(_context: IpcContext): string {
|
||||
return `${os.homedir()}/Downloads`
|
||||
const fallbackPath = path.join(os.homedir(), 'Downloads')
|
||||
|
||||
if (process.platform === 'linux' || process.platform === 'freebsd') {
|
||||
try {
|
||||
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
|
||||
if (xdgPath) {
|
||||
return xdgPath
|
||||
}
|
||||
} catch (error) {
|
||||
scopedLoggers.system.warn(
|
||||
'Unable to resolve XDG download directory, falling back to default:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -62,7 +79,7 @@ class FileSystemService extends IpcService {
|
||||
if (stats?.isDirectory()) {
|
||||
const result = await shell.openPath(normalizedPath)
|
||||
if (result) {
|
||||
console.error('Failed to open directory:', result)
|
||||
scopedLoggers.system.error('Failed to open directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -75,16 +92,16 @@ class FileSystemService extends IpcService {
|
||||
if (parentStats?.isDirectory()) {
|
||||
const result = await shell.openPath(parentDirectory)
|
||||
if (result) {
|
||||
console.error('Failed to open parent directory:', result)
|
||||
scopedLoggers.system.error('Failed to open parent directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
console.error('File or directory does not exist:', normalizedPath)
|
||||
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to open file location:', error)
|
||||
scopedLoggers.system.error('Failed to open file location:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -109,7 +126,7 @@ class FileSystemService extends IpcService {
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -137,7 +154,7 @@ class FileSystemService extends IpcService {
|
||||
await shell.openExternal(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to open external URL:', error)
|
||||
scopedLoggers.system.error('Failed to open external URL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -153,7 +170,10 @@ class FileSystemService extends IpcService {
|
||||
])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
|
||||
scopedLoggers.system.error(
|
||||
'PowerShell clipboard copy failed, falling back to manual buffer:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||
@@ -181,7 +201,10 @@ class FileSystemService extends IpcService {
|
||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
|
||||
scopedLoggers.system.error(
|
||||
'osascript clipboard copy failed, falling back to manual buffer:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
const entries = [
|
||||
@@ -216,6 +239,25 @@ class FileSystemService extends IpcService {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
|
||||
const stats = await fs.stat(normalizedPath).catch(() => null)
|
||||
|
||||
return stats?.isFile() ?? false
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to check file existence:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -263,7 +305,7 @@ class FileSystemService extends IpcService {
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
scopedLoggers.system.error('Failed to delete file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
|
||||
return historyManager.removeHistoryItem(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
removeHistoryItems(_context: IpcContext, ids: string[]): number {
|
||||
return historyManager.removeHistoryItems(ids)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
|
||||
return historyManager.removeHistoryByPlaylistId(playlistId)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
clearHistory(_context: IpcContext): void {
|
||||
historyManager.clearHistory()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getHistoryCount(_context: IpcContext): {
|
||||
active: number
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
|
||||
class SettingsService extends IpcService {
|
||||
static readonly groupName = 'settings'
|
||||
@@ -18,6 +21,18 @@ class SettingsService extends IpcService {
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'launchAtLogin') {
|
||||
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -32,11 +47,26 @@ class SettingsService extends IpcService {
|
||||
if (settings.language) {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
|
||||
if (typeof settings.launchAtLogin === 'boolean') {
|
||||
applyAutoLaunchSetting(settings.launchAtLogin)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
src/main/ipc/services/subscription-service.ts
Normal file
167
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
interface CreateSubscriptionOptions {
|
||||
url: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const ensureUrlHasProtocol = (value: string): string => {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return `https://${value}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
|
||||
const normalized = ensureUrlHasProtocol(rawUrl.trim())
|
||||
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
|
||||
if (youTubeChannelMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
|
||||
if (youTubeUserMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
|
||||
if (youTubeHandleMatch) {
|
||||
const handle = youTubeHandleMatch[1].replace('@', '')
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
|
||||
if (biliSpaceMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
if (/rsshub\.app\/bilibili/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionService extends IpcService {
|
||||
static readonly groupName = 'subscriptions'
|
||||
|
||||
@IpcMethod()
|
||||
list(_context: IpcContext): SubscriptionRule[] {
|
||||
return subscriptionManager.getAll()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
|
||||
return resolveFeedFromInput(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async create(
|
||||
_context: IpcContext,
|
||||
options: CreateSubscriptionOptions
|
||||
): Promise<SubscriptionRule> {
|
||||
const resolved = resolveFeedFromInput(options.url)
|
||||
const settings = settingsManager.getAll()
|
||||
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||
const payload: SubscriptionCreatePayload = {
|
||||
sourceUrl: resolved.sourceUrl,
|
||||
feedUrl: resolved.feedUrl,
|
||||
platform: resolved.platform,
|
||||
keywords: options.keywords,
|
||||
tags: options.tags,
|
||||
onlyDownloadLatest:
|
||||
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||
downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
),
|
||||
enabled: options.enabled ?? true
|
||||
}
|
||||
|
||||
const created = subscriptionManager.add(payload)
|
||||
void subscriptionScheduler.runNow(created.id)
|
||||
return created
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
update(
|
||||
_context: IpcContext,
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload
|
||||
): SubscriptionRule | undefined {
|
||||
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||
if (typeof normalized.namingTemplate === 'string') {
|
||||
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||
}
|
||||
return subscriptionManager.update(id, normalized)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
remove(_context: IpcContext, id: string): boolean {
|
||||
return subscriptionManager.remove(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async refresh(_context: IpcContext, id?: string): Promise<void> {
|
||||
await subscriptionScheduler.runNow(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
|
||||
return subscriptionScheduler.queueItem(id, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
export { SubscriptionService }
|
||||
@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
const isNewerVersion = (latest: string, current: string): boolean => {
|
||||
const toSegments = (version: string) =>
|
||||
version.split(/[.-]/).map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const latestSegments = toSegments(latest)
|
||||
const currentSegments = toSegments(current)
|
||||
const maxLength = Math.max(latestSegments.length, currentSegments.length)
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const latestValue = latestSegments[index] ?? 0
|
||||
const currentValue = currentSegments[index] ?? 0
|
||||
|
||||
if (latestValue > currentValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (latestValue < currentValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class UpdateService extends IpcService {
|
||||
static readonly groupName = 'update'
|
||||
|
||||
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
|
||||
_context: IpcContext
|
||||
): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
// In production, use checkForUpdatesAndNotify for automatic notifications
|
||||
const result = await autoUpdater.checkForUpdatesAndNotify()
|
||||
const currentVersion = app.getVersion()
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const latestVersion = result?.updateInfo?.version
|
||||
|
||||
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
|
||||
return {
|
||||
available: true,
|
||||
version: latestVersion
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: result !== null,
|
||||
version: result?.updateInfo?.version
|
||||
available: false,
|
||||
version: latestVersion ?? currentVersion
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
6
src/main/lib/database-path.ts
Normal file
6
src/main/lib/database-path.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
|
||||
export const getDatabaseFilePath = (): string => {
|
||||
return join(app.getPath('userData'), 'vidbee.db')
|
||||
}
|
||||
97
src/main/lib/database/migrate.ts
Normal file
97
src/main/lib/database/migrate.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import { type MigrationMeta, readMigrationFiles } from 'drizzle-orm/migrator'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
const MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'
|
||||
const MIGRATIONS_TABLE = '__drizzle_migrations'
|
||||
const KNOWN_TABLES = ['download_history', 'subscriptions', 'subscription_items']
|
||||
|
||||
const migrationsTableDefinition = sql`
|
||||
CREATE TABLE IF NOT EXISTS ${sql.identifier(MIGRATIONS_TABLE)} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`
|
||||
|
||||
export const runMigrations = (database: BetterSQLite3Database): void => {
|
||||
const migrationsFolder = resolveMigrationsFolder()
|
||||
if (!migrationsFolder) {
|
||||
log.warn('database: drizzle migrations folder not found, skipping migrations')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ensureBaseline(database, migrationsFolder)
|
||||
migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||
} catch (error) {
|
||||
log.error('database: failed to run drizzle migrations', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMigrationsFolder = (): string | null => {
|
||||
const candidates = new Set<string>()
|
||||
candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))
|
||||
candidates.add(resolve(__dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))
|
||||
|
||||
if (process.resourcesPath) {
|
||||
candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))
|
||||
candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))
|
||||
}
|
||||
|
||||
try {
|
||||
candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))
|
||||
} catch {
|
||||
// app might not be ready yet, ignore
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const ensureBaseline = (database: BetterSQLite3Database, migrationsFolder: string): void => {
|
||||
if (hasTable(database, MIGRATIONS_TABLE)) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasExistingSchema = KNOWN_TABLES.some((table) => hasTable(database, table))
|
||||
if (!hasExistingSchema) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('database: detected existing schema, seeding drizzle migrations baseline')
|
||||
database.run(migrationsTableDefinition)
|
||||
|
||||
let migrations: MigrationMeta[]
|
||||
try {
|
||||
migrations = readMigrationFiles({ migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||
} catch (error) {
|
||||
log.error('database: failed to read drizzle migrations while seeding baseline', error)
|
||||
return
|
||||
}
|
||||
|
||||
database.transaction((tx) => {
|
||||
for (const migration of migrations) {
|
||||
tx.run(
|
||||
sql`INSERT INTO ${sql.identifier(MIGRATIONS_TABLE)} ("hash", "created_at") VALUES(${migration.hash}, ${migration.folderMillis})`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hasTable = (database: BetterSQLite3Database, tableName: string): boolean => {
|
||||
const rows = database.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}`
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
83
src/main/lib/database/schema.ts
Normal file
83
src/main/lib/database/schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const downloadHistoryTable = sqliteTable('download_history', {
|
||||
id: text('id').primaryKey(),
|
||||
url: text('url').notNull(),
|
||||
title: text('title').notNull(),
|
||||
thumbnail: text('thumbnail'),
|
||||
type: text('type').notNull(),
|
||||
status: text('status').notNull(),
|
||||
downloadPath: text('download_path'),
|
||||
savedFileName: text('saved_file_name'),
|
||||
fileSize: integer('file_size', { mode: 'number' }),
|
||||
duration: integer('duration', { mode: 'number' }),
|
||||
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
error: text('error'),
|
||||
description: text('description'),
|
||||
channel: text('channel'),
|
||||
uploader: text('uploader'),
|
||||
viewCount: integer('view_count', { mode: 'number' }),
|
||||
tags: text('tags'),
|
||||
origin: text('origin'),
|
||||
subscriptionId: text('subscription_id'),
|
||||
selectedFormat: text('selected_format'),
|
||||
playlistId: text('playlist_id'),
|
||||
playlistTitle: text('playlist_title'),
|
||||
playlistIndex: integer('playlist_index', { mode: 'number' }),
|
||||
playlistSize: integer('playlist_size', { mode: 'number' })
|
||||
})
|
||||
|
||||
export const subscriptionsTable = sqliteTable('subscriptions', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
sourceUrl: text('source_url').notNull(),
|
||||
feedUrl: text('feed_url').notNull(),
|
||||
platform: text('platform').notNull(),
|
||||
keywords: text('keywords').notNull(),
|
||||
tags: text('tags').notNull(),
|
||||
onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),
|
||||
enabled: integer('enabled', { mode: 'number' }).notNull(),
|
||||
coverUrl: text('cover_url'),
|
||||
latestVideoTitle: text('latest_video_title'),
|
||||
latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),
|
||||
lastCheckedAt: integer('last_checked_at', { mode: 'number' }),
|
||||
lastSuccessAt: integer('last_success_at', { mode: 'number' }),
|
||||
status: text('status').notNull(),
|
||||
lastError: text('last_error'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
downloadDirectory: text('download_directory'),
|
||||
namingTemplate: text('naming_template')
|
||||
})
|
||||
|
||||
export const subscriptionItemsTable = sqliteTable(
|
||||
'subscription_items',
|
||||
{
|
||||
subscriptionId: text('subscription_id').notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
url: text('url').notNull(),
|
||||
publishedAt: integer('published_at', { mode: 'number' }).notNull(),
|
||||
thumbnail: text('thumbnail'),
|
||||
added: integer('added', { mode: 'number' }).notNull(),
|
||||
downloadId: text('download_id'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull()
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.subscriptionId, table.itemId],
|
||||
name: 'subscription_items_pk'
|
||||
}),
|
||||
subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)
|
||||
})
|
||||
)
|
||||
|
||||
export type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
|
||||
export type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
|
||||
export type SubscriptionRow = typeof subscriptionsTable.$inferSelect
|
||||
export type SubscriptionInsert = typeof subscriptionsTable.$inferInsert
|
||||
export type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect
|
||||
export type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||
import type {
|
||||
@@ -7,11 +8,16 @@ import type {
|
||||
DownloadOptions,
|
||||
DownloadProgress,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} from '../../shared/types'
|
||||
import { buildDownloadArgs } from '../download-engine/args-builder'
|
||||
import {
|
||||
buildDownloadArgs,
|
||||
resolveVideoFormatSelector,
|
||||
sanitizeFilenameTemplate
|
||||
} from '../download-engine/args-builder'
|
||||
import {
|
||||
findFormatByIdCandidates,
|
||||
parseSizeToBytes,
|
||||
@@ -19,7 +25,9 @@ import {
|
||||
} from '../download-engine/format-utils'
|
||||
import { settingsManager } from '../settings'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
import { DownloadQueue } from './download-queue'
|
||||
import { ffmpegManager } from './ffmpeg-manager'
|
||||
import { historyManager } from './history-manager'
|
||||
import { ytdlpManager } from './ytdlp-manager'
|
||||
|
||||
@@ -28,6 +36,122 @@ interface DownloadProcess {
|
||||
process: YTDlpEventEmitter
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeFolderName = (value: string, fallback: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return fallback
|
||||
}
|
||||
const sanitized = trimmed
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[. ]+$/g, '')
|
||||
return sanitized || fallback
|
||||
}
|
||||
|
||||
const isLikelyChannelUrl = (url: string): boolean => {
|
||||
const normalized = url.toLowerCase()
|
||||
if (normalized.includes('list=')) {
|
||||
return false
|
||||
}
|
||||
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
|
||||
}
|
||||
|
||||
const resolveAutoPlaylistDownloadPath = (
|
||||
basePath: string,
|
||||
info: PlaylistInfo,
|
||||
url: string
|
||||
): string => {
|
||||
const kindFolder = isLikelyChannelUrl(url) ? 'Channels' : 'Playlists'
|
||||
const title = sanitizeFolderName(
|
||||
info.title || (kindFolder === 'Channels' ? 'Channel' : 'Playlist'),
|
||||
kindFolder === 'Channels' ? 'Channel' : 'Playlist'
|
||||
)
|
||||
return path.join(basePath, kindFolder, title)
|
||||
}
|
||||
|
||||
const resolveAutoVideoDownloadPath = (basePath: string, info?: VideoInfo): string => {
|
||||
const root = path.join(basePath, 'Videos')
|
||||
if (!info) {
|
||||
return root
|
||||
}
|
||||
const label = info.uploader?.trim() || info.title?.trim()
|
||||
if (!label) {
|
||||
return root
|
||||
}
|
||||
return path.join(root, sanitizeFolderName(label, 'Video'))
|
||||
}
|
||||
|
||||
const sanitizeTemplateValue = (value: string): string =>
|
||||
value
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '')
|
||||
|
||||
const resolveTemplateToken = (token: string, info?: VideoInfo): string | undefined => {
|
||||
if (!info) {
|
||||
return undefined
|
||||
}
|
||||
switch (token) {
|
||||
case 'uploader':
|
||||
return info.uploader
|
||||
case 'title':
|
||||
return info.title
|
||||
case 'id':
|
||||
return info.id
|
||||
case 'channel':
|
||||
return info.uploader
|
||||
case 'extractor':
|
||||
return info.extractor_key
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const resolveHistoryDownloadPath = (
|
||||
basePath: string,
|
||||
filenameTemplate?: string,
|
||||
info?: VideoInfo
|
||||
): string => {
|
||||
if (!filenameTemplate?.trim()) {
|
||||
return basePath
|
||||
}
|
||||
const safeTemplate = sanitizeFilenameTemplate(filenameTemplate)
|
||||
const resolvedTemplate = safeTemplate.replace(/%\(([^)]+)\)s/g, (match, token) => {
|
||||
const value = resolveTemplateToken(token, info)
|
||||
if (!value) {
|
||||
return match
|
||||
}
|
||||
return sanitizeTemplateValue(value)
|
||||
})
|
||||
const templateDir = path.posix.dirname(resolvedTemplate)
|
||||
if (templateDir === '.' || templateDir === '/') {
|
||||
return basePath
|
||||
}
|
||||
if (/%\([^)]+\)s/.test(templateDir)) {
|
||||
return basePath
|
||||
}
|
||||
return path.join(basePath, templateDir)
|
||||
}
|
||||
|
||||
const appendJsRuntimeArgs = (args: string[]): void => {
|
||||
const runtimeArgs = ytdlpManager.getJsRuntimeArgs()
|
||||
if (runtimeArgs.length > 0) {
|
||||
args.push(...runtimeArgs)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
@@ -51,6 +175,11 @@ class DownloadEngine extends EventEmitter {
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
@@ -61,11 +190,18 @@ class DownloadEngine extends EventEmitter {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -85,6 +221,27 @@ class DownloadEngine extends EventEmitter {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const info = JSON.parse(stdout)
|
||||
|
||||
// Calculate estimated file size for formats missing filesize information
|
||||
// Using tbr (total bitrate in kbps) and duration (in seconds)
|
||||
// Formula: (tbr * 1000) / 8 * duration = size in bytes
|
||||
if (info.formats && Array.isArray(info.formats) && info.duration) {
|
||||
const duration = info.duration
|
||||
for (const format of info.formats) {
|
||||
if (
|
||||
!format.filesize &&
|
||||
!format.filesize_approx &&
|
||||
format.tbr &&
|
||||
typeof format.tbr === 'number' &&
|
||||
duration > 0
|
||||
) {
|
||||
// Calculate estimated size: tbr (kbps) * 1000 / 8 bits per byte * duration (seconds)
|
||||
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
|
||||
format.filesize_approx = estimatedSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopedLoggers.download.info('Successfully retrieved video info for:', url)
|
||||
resolve(info)
|
||||
} catch (error) {
|
||||
@@ -115,7 +272,7 @@ class DownloadEngine extends EventEmitter {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const args = ['-j', '--flat-playlist', '--no-warnings']
|
||||
const args = ['-J', '--flat-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
@@ -130,8 +287,59 @@ class DownloadEngine extends EventEmitter {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
type RawPlaylistEntry = {
|
||||
id?: string
|
||||
title?: string
|
||||
url?: string
|
||||
webpage_url?: string
|
||||
original_url?: string
|
||||
ie_key?: string
|
||||
}
|
||||
|
||||
const resolveEntryUrl = (entry: RawPlaylistEntry): string => {
|
||||
if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) {
|
||||
return entry.url
|
||||
}
|
||||
if (entry.webpage_url && typeof entry.webpage_url === 'string') {
|
||||
return entry.webpage_url
|
||||
}
|
||||
if (entry.original_url && typeof entry.original_url === 'string') {
|
||||
return entry.original_url
|
||||
}
|
||||
if (entry.url && typeof entry.url === 'string') {
|
||||
if (entry.ie_key && typeof entry.ie_key === 'string') {
|
||||
const extractor = entry.ie_key.toLowerCase()
|
||||
if (extractor.includes('youtube')) {
|
||||
return `https://www.youtube.com/watch?v=${entry.url}`
|
||||
}
|
||||
if (extractor.includes('youtubemusic')) {
|
||||
return `https://music.youtube.com/watch?v=${entry.url}`
|
||||
}
|
||||
}
|
||||
if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) {
|
||||
return entry.url
|
||||
}
|
||||
}
|
||||
if (entry.id && typeof entry.id === 'string') {
|
||||
return entry.id
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = ytdlp.exec(args)
|
||||
let stdout = ''
|
||||
@@ -148,60 +356,128 @@ class DownloadEngine extends EventEmitter {
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const lines = stdout.trim().split('\n')
|
||||
const entries = lines.map((line) => JSON.parse(line))
|
||||
const playlistEntry = entries[0]
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
id?: string
|
||||
title?: string
|
||||
entries?: RawPlaylistEntry[]
|
||||
}
|
||||
const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : []
|
||||
const entries = rawEntries
|
||||
.map((entry, index) => {
|
||||
const resolvedUrl = resolveEntryUrl(entry)
|
||||
return {
|
||||
id: entry.id || `${index}`,
|
||||
title: entry.title || `Entry ${index + 1}`,
|
||||
url: resolvedUrl,
|
||||
index: index + 1
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.url)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Successfully retrieved playlist info for:',
|
||||
url,
|
||||
'entries:',
|
||||
entries.length
|
||||
)
|
||||
resolve({
|
||||
id: playlistEntry.id || '',
|
||||
title: playlistEntry.title || 'Playlist',
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry.id || '',
|
||||
title: entry.title || 'Unknown',
|
||||
url: entry.url || entry.webpage_url || ''
|
||||
})),
|
||||
id: parsed.id || url,
|
||||
title: parsed.title || 'Playlist',
|
||||
entries,
|
||||
entryCount: entries.length
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to parse playlist info for:', url, error)
|
||||
reject(new Error(`Failed to parse playlist info: ${error}`))
|
||||
}
|
||||
} else {
|
||||
scopedLoggers.download.error(
|
||||
'Failed to fetch playlist info for:',
|
||||
url,
|
||||
'Exit code:',
|
||||
code,
|
||||
'Error:',
|
||||
stderr
|
||||
)
|
||||
reject(new Error(stderr || 'Failed to fetch playlist info'))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<string[]> {
|
||||
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
|
||||
const playlistInfo = await this.getPlaylistInfo(options.url)
|
||||
const downloadIds: string[] = []
|
||||
const downloadEntries: PlaylistDownloadResult['entries'] = []
|
||||
const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
// Calculate the range of entries to download
|
||||
const startIndex = (options.startIndex || 1) - 1 // Convert to 0-based index
|
||||
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
|
||||
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
|
||||
const totalEntries = playlistInfo.entries.length
|
||||
if (totalEntries === 0) {
|
||||
scopedLoggers.download.warn('Playlist has no entries:', options.url)
|
||||
return {
|
||||
groupId,
|
||||
playlistId: playlistInfo.id,
|
||||
playlistTitle: playlistInfo.title,
|
||||
type: options.type,
|
||||
totalCount: 0,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
entries: []
|
||||
}
|
||||
}
|
||||
|
||||
const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0)
|
||||
const requestedEnd = options.endIndex
|
||||
? Math.min(options.endIndex - 1, totalEntries - 1)
|
||||
: totalEntries - 1
|
||||
const rangeStart = Math.min(requestedStart, requestedEnd)
|
||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
||||
const settings = settingsManager.getAll()
|
||||
const resolvedDownloadPath =
|
||||
options.customDownloadPath?.trim() ||
|
||||
resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)
|
||||
ensureDirectoryExists(resolvedDownloadPath)
|
||||
|
||||
const selectedEntries = rawEntries.filter((entry) => {
|
||||
if (!entry.url) {
|
||||
scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const selectionSize = selectedEntries.length
|
||||
|
||||
scopedLoggers.download.info(
|
||||
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
|
||||
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
|
||||
)
|
||||
|
||||
// Create download items for each video in the playlist
|
||||
for (const entry of entriesToDownload) {
|
||||
const downloadId = `playlist_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
downloadIds.push(downloadId)
|
||||
for (const entry of selectedEntries) {
|
||||
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
||||
|
||||
const downloadOptions: DownloadOptions = {
|
||||
url: entry.url,
|
||||
type: options.type,
|
||||
format: options.format,
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||
customDownloadPath: resolvedDownloadPath
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
downloadEntries.push({
|
||||
downloadId,
|
||||
entryId: entry.id,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
index: entry.index
|
||||
})
|
||||
|
||||
// Add to queue
|
||||
this.queue.add(downloadId, downloadOptions, {
|
||||
@@ -211,27 +487,53 @@ class DownloadEngine extends EventEmitter {
|
||||
type: options.type,
|
||||
status: 'pending',
|
||||
progress: { percent: 0 },
|
||||
createdAt
|
||||
createdAt,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
|
||||
this.upsertHistoryEntry(downloadId, downloadOptions, {
|
||||
title: entry.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
}
|
||||
|
||||
return downloadIds
|
||||
return {
|
||||
groupId,
|
||||
playlistId: playlistInfo.id,
|
||||
playlistTitle: playlistInfo.title,
|
||||
type: options.type,
|
||||
totalCount: selectionSize,
|
||||
startIndex: selectedEntries[0]?.index ?? rangeStart + 1,
|
||||
endIndex: selectedEntries[selectedEntries.length - 1]?.index ?? rangeEnd + 1,
|
||||
entries: downloadEntries
|
||||
}
|
||||
}
|
||||
|
||||
startDownload(id: string, options: DownloadOptions): void {
|
||||
if (this.activeDownloads.has(id)) {
|
||||
console.warn(`Download ${id} is already active`)
|
||||
scopedLoggers.engine.warn(`Download ${id} is already active`)
|
||||
return
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
const settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||
targetDownloadPath,
|
||||
options.customFilenameTemplate
|
||||
)
|
||||
ensureDirectoryExists(targetDownloadPath)
|
||||
ensureDirectoryExists(historyDownloadPath)
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -239,7 +541,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
@@ -248,7 +553,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath
|
||||
downloadPath: historyDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,7 +564,8 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadPath = settings.downloadPath
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
@@ -267,9 +576,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let actualQuality: string | null = null
|
||||
let actualCodec: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -281,20 +589,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
if (selectedFormat) {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
|
||||
if (selectedFormat.height) {
|
||||
actualQuality = `${selectedFormat.height}p${
|
||||
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
|
||||
}`
|
||||
} else if (selectedFormat.format_note) {
|
||||
actualQuality = selectedFormat.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = selectedFormat.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
@@ -322,6 +616,19 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
|
||||
if (!options.customDownloadPath?.trim()) {
|
||||
resolvedDownloadPath = resolveAutoVideoDownloadPath(defaultDownloadPath, videoInfo)
|
||||
options.customDownloadPath = resolvedDownloadPath
|
||||
}
|
||||
|
||||
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||
resolvedDownloadPath,
|
||||
options.customFilenameTemplate,
|
||||
videoInfo
|
||||
)
|
||||
ensureDirectoryExists(historyDownloadPath)
|
||||
this.upsertHistoryEntry(id, options, { downloadPath: historyDownloadPath })
|
||||
|
||||
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
||||
if (!formatId) {
|
||||
return false
|
||||
@@ -339,18 +646,6 @@ class DownloadEngine extends EventEmitter {
|
||||
selectedFormat = candidate
|
||||
actualFormat = candidate.ext || actualFormat
|
||||
|
||||
if (candidate.height) {
|
||||
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
|
||||
} else if (candidate.format_note) {
|
||||
actualQuality = candidate.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = candidate.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
@@ -358,7 +653,84 @@ class DownloadEngine extends EventEmitter {
|
||||
return true
|
||||
}
|
||||
|
||||
const args = buildDownloadArgs(options, downloadPath, settings)
|
||||
const args = buildDownloadArgs(
|
||||
options,
|
||||
resolvedDownloadPath,
|
||||
settings,
|
||||
ytdlpManager.getJsRuntimeArgs()
|
||||
)
|
||||
|
||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||
if (!rawPath) {
|
||||
return
|
||||
}
|
||||
const trimmed = rawPath.trim().replace(/^"|"$/g, '')
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
lastKnownOutputPath = path.isAbsolute(trimmed)
|
||||
? trimmed
|
||||
: path.join(resolvedDownloadPath, trimmed)
|
||||
}
|
||||
|
||||
const extractOutputPathFromLog = (message: string): void => {
|
||||
const destinationMatch = message.match(/Destination:\s*(.+)$/)
|
||||
if (destinationMatch) {
|
||||
captureOutputPath(destinationMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/)
|
||||
if (mergingMatch) {
|
||||
captureOutputPath(mergingMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const movingMatch = message.match(/Moving file to\s+"(.+?)"/)
|
||||
if (movingMatch) {
|
||||
captureOutputPath(movingMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Check if format selector contains '+' which means video and audio will be merged
|
||||
const formatSelector =
|
||||
options.type === 'video' ? resolveVideoFormatSelector(options) : undefined
|
||||
const willMerge = formatSelector?.includes('+') ?? false
|
||||
|
||||
const urlArg = args.pop()
|
||||
if (!urlArg) {
|
||||
const missingUrlError = new Error('Download arguments missing URL.')
|
||||
scopedLoggers.download.error('Missing URL argument for download ID:', id)
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'error',
|
||||
completedAt: Date.now(),
|
||||
error: missingUrlError.message
|
||||
})
|
||||
this.queue.downloadCompleted(id)
|
||||
this.emit('download-error', id, missingUrlError)
|
||||
this.addToHistory(id, options, 'error', missingUrlError.message)
|
||||
return
|
||||
}
|
||||
|
||||
let ffmpegPath: string
|
||||
try {
|
||||
ffmpegPath = ffmpegManager.getPath()
|
||||
} catch (error) {
|
||||
const ffmpegError = error instanceof Error ? error : new Error(String(error))
|
||||
scopedLoggers.download.error('Failed to resolve ffmpeg for download ID:', id, ffmpegError)
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'error',
|
||||
completedAt: Date.now(),
|
||||
error: ffmpegError.message
|
||||
})
|
||||
this.queue.downloadCompleted(id)
|
||||
this.emit('download-error', id, ffmpegError)
|
||||
this.addToHistory(id, options, 'error', ffmpegError.message)
|
||||
return
|
||||
}
|
||||
|
||||
args.push('--ffmpeg-location', ffmpegPath)
|
||||
args.push(urlArg)
|
||||
|
||||
const controller = new AbortController()
|
||||
const ytdlpProcess = ytdlp.exec(args, {
|
||||
@@ -426,16 +798,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (extMatch && !actualFormat) {
|
||||
actualFormat = extMatch[1]
|
||||
}
|
||||
|
||||
const heightMatch = formatInfo.match(/(\d+)p/)
|
||||
if (heightMatch && !actualQuality) {
|
||||
actualQuality = `${heightMatch[1]}p`
|
||||
}
|
||||
|
||||
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
|
||||
if (codecMatch && !actualCodec) {
|
||||
actualCodec = codecMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +808,10 @@ class DownloadEngine extends EventEmitter {
|
||||
applySelectedFormat(formatMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
@@ -457,23 +823,96 @@ class DownloadEngine extends EventEmitter {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const title = videoInfo?.title || 'Unknown'
|
||||
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
|
||||
const extension =
|
||||
options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4'
|
||||
const fileName = `${sanitizedTitle}.${extension}`
|
||||
const finalOutputPath = path.join(downloadPath, fileName)
|
||||
|
||||
scopedLoggers.download.info('Generated file path for ID:', id, 'Path:', finalOutputPath)
|
||||
// Determine file extension based on download type and format
|
||||
// yt-dlp automatically chooses the best merge format (mkv/webm/mp4)
|
||||
// based on codec compatibility, so we should use actualFormat when available
|
||||
let extension: string
|
||||
if (options.type === 'audio') {
|
||||
extension = options.extractFormat || 'mp3'
|
||||
} else if (willMerge) {
|
||||
// For merged files, yt-dlp auto-selects format (mkv/webm/mp4)
|
||||
// Use actualFormat if available, otherwise default to mkv (most compatible)
|
||||
extension = actualFormat || 'mkv'
|
||||
} else {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const stats = await fs.stat(finalOutputPath)
|
||||
fileSize = stats.size
|
||||
let located = false
|
||||
for (const candidate of candidatePaths) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(candidate)
|
||||
fileSize = stats.size
|
||||
actualFilePath = candidate
|
||||
located = true
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!located) {
|
||||
const files = await fs.readdir(resolvedDownloadPath)
|
||||
const matchingFiles = files.filter((file) => {
|
||||
const baseName = file.replace(/\.[^.]+$/, '')
|
||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||
return (
|
||||
(baseName === sanitizedTitle || baseName.startsWith(sanitizedTitle)) &&
|
||||
fileExt === extension.toLowerCase()
|
||||
)
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
return { path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
})
|
||||
)
|
||||
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
||||
actualFilePath = mostRecent.path
|
||||
fileSize = mostRecent.size
|
||||
located = true
|
||||
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
if (!located) {
|
||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||
}
|
||||
} else if (!fileSize) {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id)
|
||||
}
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,13 +920,13 @@ class DownloadEngine extends EventEmitter {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
|
||||
const savedFileName = path.basename(actualFilePath)
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
this.emit('download-completed', id)
|
||||
@@ -573,15 +1012,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.fileSize !== undefined) {
|
||||
historyUpdates.fileSize = updates.fileSize
|
||||
}
|
||||
if (updates.format !== undefined) {
|
||||
historyUpdates.format = updates.format
|
||||
}
|
||||
if (updates.quality !== undefined) {
|
||||
historyUpdates.quality = updates.quality
|
||||
}
|
||||
if (updates.codec !== undefined) {
|
||||
historyUpdates.codec = updates.codec
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
@@ -597,6 +1027,21 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.tags !== undefined) {
|
||||
historyUpdates.tags = updates.tags
|
||||
}
|
||||
if (updates.playlistId !== undefined) {
|
||||
historyUpdates.playlistId = updates.playlistId
|
||||
}
|
||||
if (updates.playlistTitle !== undefined) {
|
||||
historyUpdates.playlistTitle = updates.playlistTitle
|
||||
}
|
||||
if (updates.playlistIndex !== undefined) {
|
||||
historyUpdates.playlistIndex = updates.playlistIndex
|
||||
}
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -606,8 +1051,8 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
@@ -634,14 +1079,17 @@ class DownloadEngine extends EventEmitter {
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
format: completedDownload?.item.format,
|
||||
quality: completedDownload?.item.quality,
|
||||
codec: completedDownload?.item.codec,
|
||||
description: completedDownload?.item.description,
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
viewCount: completedDownload?.item.viewCount,
|
||||
tags: completedDownload?.item.tags
|
||||
tags: completedDownload?.item.tags,
|
||||
origin: completedDownload?.item.origin,
|
||||
subscriptionId: completedDownload?.item.subscriptionId,
|
||||
playlistId: completedDownload?.item.playlistId,
|
||||
playlistTitle: completedDownload?.item.playlistTitle,
|
||||
playlistIndex: completedDownload?.item.playlistIndex,
|
||||
playlistSize: completedDownload?.item.playlistSize
|
||||
})
|
||||
}
|
||||
|
||||
@@ -651,6 +1099,8 @@ class DownloadEngine extends EventEmitter {
|
||||
updates: Partial<DownloadHistoryItem>
|
||||
): void {
|
||||
const existing = historyManager.getHistoryById(id)
|
||||
const resolvedDownloadPath =
|
||||
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||
const base: DownloadHistoryItem = existing ?? {
|
||||
id,
|
||||
url: options.url,
|
||||
@@ -658,22 +1108,26 @@ class DownloadEngine extends EventEmitter {
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
format: updates.format,
|
||||
quality: updates.quality,
|
||||
codec: updates.codec,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
viewCount: updates.viewCount,
|
||||
tags: updates.tags,
|
||||
tags: updates.tags ?? options.tags,
|
||||
origin: updates.origin ?? options.origin,
|
||||
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
|
||||
// Download-specific format info
|
||||
selectedFormat: updates.selectedFormat
|
||||
selectedFormat: updates.selectedFormat,
|
||||
playlistId: updates.playlistId,
|
||||
playlistTitle: updates.playlistTitle,
|
||||
playlistIndex: updates.playlistIndex,
|
||||
playlistSize: updates.playlistSize
|
||||
}
|
||||
|
||||
const merged: DownloadHistoryItem = {
|
||||
@@ -684,7 +1138,11 @@ class DownloadEngine extends EventEmitter {
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
status: updates.status ?? base.status,
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
|
||||
downloadPath: resolvedDownloadPath ?? base.downloadPath,
|
||||
tags: updates.tags ?? base.tags,
|
||||
origin: updates.origin ?? base.origin,
|
||||
subscriptionId: updates.subscriptionId ?? base.subscriptionId
|
||||
}
|
||||
|
||||
historyManager.addHistoryItem(merged)
|
||||
|
||||
105
src/main/lib/ffmpeg-manager.ts
Normal file
105
src/main/lib/ffmpeg-manager.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
class FfmpegManager {
|
||||
private ffmpegPath: string | null = null
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ffmpegPath = await this.findFfmpegBinary()
|
||||
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
if (!this.ffmpegPath) {
|
||||
throw new Error('ffmpeg not initialized. Call initialize() first.')
|
||||
}
|
||||
return this.ffmpegPath
|
||||
}
|
||||
|
||||
private getResourcesPath(): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return path.join(process.cwd(), 'resources')
|
||||
}
|
||||
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
|
||||
}
|
||||
|
||||
private async findFfmpegBinary(): Promise<string> {
|
||||
const platform = os.platform()
|
||||
const resourceCandidates: string[] = []
|
||||
|
||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
||||
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
||||
return process.env.FFMPEG_PATH
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
resourceCandidates.push('ffmpeg.exe')
|
||||
} else if (platform === 'darwin') {
|
||||
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
|
||||
} else {
|
||||
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
|
||||
}
|
||||
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
for (const candidate of resourceCandidates) {
|
||||
const fullPath = path.join(resourcesPath, candidate)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
scopedLoggers.engine.warn(
|
||||
'Failed to set executable permission on ffmpeg binary:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
||||
for (const candidate of commonPaths) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux' || platform === 'freebsd') {
|
||||
try {
|
||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
try {
|
||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
||||
return output
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const ffmpegManager = new FfmpegManager()
|
||||
@@ -1,52 +1,476 @@
|
||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||
const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { eq, inArray, sql } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import type { DownloadHistoryItem } from '../../shared/types'
|
||||
import { runMigrations } from './database/migrate'
|
||||
import {
|
||||
type DownloadHistoryInsert,
|
||||
type DownloadHistoryRow,
|
||||
downloadHistoryTable
|
||||
} from './database/schema'
|
||||
import { getDatabaseFilePath } from './database-path'
|
||||
|
||||
const logger = log.scope('history-manager')
|
||||
|
||||
const TAG_SEPARATOR = '\n'
|
||||
|
||||
const createDownloadHistoryTableSql = sql`
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
thumbnail TEXT,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
download_path TEXT,
|
||||
saved_file_name TEXT,
|
||||
file_size INTEGER,
|
||||
duration INTEGER,
|
||||
downloaded_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
sort_key INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
description TEXT,
|
||||
channel TEXT,
|
||||
uploader TEXT,
|
||||
view_count INTEGER,
|
||||
tags TEXT,
|
||||
origin TEXT,
|
||||
subscription_id TEXT,
|
||||
selected_format TEXT,
|
||||
playlist_id TEXT,
|
||||
playlist_title TEXT,
|
||||
playlist_index INTEGER,
|
||||
playlist_size INTEGER
|
||||
)
|
||||
`
|
||||
|
||||
const renameDownloadHistoryTableSql = sql`
|
||||
ALTER TABLE download_history RENAME TO download_history_legacy
|
||||
`
|
||||
|
||||
const dropLegacyDownloadHistoryTableSql = sql`
|
||||
DROP TABLE download_history_legacy
|
||||
`
|
||||
|
||||
const copyDownloadHistoryFromLegacySql = sql`
|
||||
INSERT INTO download_history (
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
type,
|
||||
status,
|
||||
download_path,
|
||||
saved_file_name,
|
||||
file_size,
|
||||
duration,
|
||||
downloaded_at,
|
||||
completed_at,
|
||||
sort_key,
|
||||
error,
|
||||
description,
|
||||
channel,
|
||||
uploader,
|
||||
view_count,
|
||||
tags,
|
||||
origin,
|
||||
subscription_id,
|
||||
selected_format,
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
playlist_index,
|
||||
playlist_size
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
type,
|
||||
status,
|
||||
download_path,
|
||||
saved_file_name,
|
||||
file_size,
|
||||
duration,
|
||||
downloaded_at,
|
||||
completed_at,
|
||||
sort_key,
|
||||
error,
|
||||
description,
|
||||
channel,
|
||||
uploader,
|
||||
view_count,
|
||||
tags,
|
||||
origin,
|
||||
subscription_id,
|
||||
selected_format,
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
playlist_index,
|
||||
playlist_size
|
||||
FROM download_history_legacy
|
||||
`
|
||||
|
||||
const sanitizeList = (values?: string[]): string[] => {
|
||||
if (!values || values.length === 0) {
|
||||
return []
|
||||
}
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||
}
|
||||
|
||||
const serializeTags = (values?: string[]): string | null => {
|
||||
const sanitized = sanitizeList(values)
|
||||
return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null
|
||||
}
|
||||
|
||||
const parseTags = (value: string | null): string[] | undefined => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = value
|
||||
.split(TAG_SEPARATOR)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)
|
||||
return parsed.length > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
|
||||
id: text('id').primaryKey(),
|
||||
status: text('status').notNull(),
|
||||
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
payload: text('payload').notNull()
|
||||
})
|
||||
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
|
||||
|
||||
class HistoryManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
private db: BetterSQLite3Database | null = null
|
||||
private history: Map<string, DownloadHistoryItem> = new Map()
|
||||
private schemaChecked = false
|
||||
private migrationChecked = false
|
||||
|
||||
constructor() {
|
||||
this.store = new Store({
|
||||
name: 'download-history',
|
||||
defaults: {
|
||||
items: []
|
||||
}
|
||||
})
|
||||
this.loadHistory()
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
private initialize(): void {
|
||||
try {
|
||||
const historyArray = this.store.get('items', [])
|
||||
this.history = new Map(historyArray.map((item) => [item.id, item]))
|
||||
this.getDatabase()
|
||||
this.ensureStructuredSchema()
|
||||
this.ensureLegacyMigration()
|
||||
this.loadHistoryFromDatabase()
|
||||
} catch (error) {
|
||||
console.error('Failed to load download history:', error)
|
||||
logger.error('history-db failed to initialize', error)
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
private getDatabase(): BetterSQLite3Database {
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
|
||||
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
const database = drizzle(sqlite)
|
||||
runMigrations(database)
|
||||
|
||||
this.db = database
|
||||
logger.info(`history-db initialized at ${databasePath}`)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private getLegacyStorePath(): string {
|
||||
return join(app.getPath('userData'), 'download-history.json')
|
||||
}
|
||||
|
||||
private ensureStructuredSchema(): void {
|
||||
if (this.schemaChecked) {
|
||||
return
|
||||
}
|
||||
this.schemaChecked = true
|
||||
|
||||
try {
|
||||
const historyArray = Array.from(this.history.values())
|
||||
this.store.set('items', historyArray)
|
||||
const database = this.getDatabase()
|
||||
const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`)
|
||||
const hasPayloadColumn = columns.some((column) => column.name === 'payload')
|
||||
const hasUrlColumn = columns.some((column) => column.name === 'url')
|
||||
if (hasPayloadColumn || !hasUrlColumn) {
|
||||
this.migrateLegacyPayloadTable()
|
||||
return
|
||||
}
|
||||
|
||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
if (needsRebuild) {
|
||||
this.rebuildDownloadHistoryTable()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save download history:', error)
|
||||
logger.error('history-db failed to inspect schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private migrateLegacyPayloadTable(): void {
|
||||
const database = this.getDatabase()
|
||||
logger.info('history-db migrating legacy payload schema to structured columns')
|
||||
|
||||
try {
|
||||
let migratedCount = 0
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
tx.run(renameDownloadHistoryTableSql)
|
||||
tx.run(createDownloadHistoryTableSql)
|
||||
|
||||
const legacyRows = tx.select().from(legacyDownloadHistoryTable).all()
|
||||
migratedCount = legacyRows.length
|
||||
for (const legacyRow of legacyRows) {
|
||||
const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow))
|
||||
tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run()
|
||||
}
|
||||
|
||||
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||
},
|
||||
{ behavior: 'immediate' }
|
||||
)
|
||||
logger.info(`history-db migrated ${migratedCount} rows to new schema`)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to migrate legacy payload rows', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildDownloadHistoryTable(): void {
|
||||
const database = this.getDatabase()
|
||||
logger.info('history-db rebuilding download_history table to latest schema')
|
||||
|
||||
try {
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
tx.run(renameDownloadHistoryTableSql)
|
||||
tx.run(createDownloadHistoryTableSql)
|
||||
tx.run(copyDownloadHistoryFromLegacySql)
|
||||
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||
},
|
||||
{ behavior: 'immediate' }
|
||||
)
|
||||
logger.info('history-db rebuilt download_history table')
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to rebuild download_history schema', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem {
|
||||
try {
|
||||
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
|
||||
return {
|
||||
...parsed,
|
||||
status: (parsed.status ?? row.status) as DownloadHistoryItem['status'],
|
||||
downloadedAt: parsed.downloadedAt ?? row.downloadedAt,
|
||||
completedAt: parsed.completedAt ?? row.completedAt ?? undefined
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('history-db falling back while migrating payload row', { id: row.id, error })
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.id,
|
||||
title: `Download ${row.id}`,
|
||||
type: 'video',
|
||||
status: row.status as DownloadHistoryItem['status'],
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLegacyMigration(): void {
|
||||
if (this.migrationChecked) {
|
||||
return
|
||||
}
|
||||
this.migrationChecked = true
|
||||
|
||||
const legacyPath = this.getLegacyStorePath()
|
||||
if (!existsSync(legacyPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(legacyPath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as { items?: DownloadHistoryItem[] }
|
||||
const items = parsed.items ?? []
|
||||
if (items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
for (const legacyItem of items) {
|
||||
const normalized = this.normalizeItem(legacyItem)
|
||||
const insertPayload = this.mapItemToInsert(normalized)
|
||||
database
|
||||
.insert(downloadHistoryTable)
|
||||
.values(insertPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: downloadHistoryTable.id,
|
||||
set: this.mapItemToUpdate(insertPayload)
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(legacyPath, `${legacyPath}.bak`)
|
||||
} catch (renameError) {
|
||||
logger.warn(
|
||||
'history-db migrated legacy store but could not rename original file',
|
||||
renameError
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`history-db migrated ${items.length} entries from legacy electron-store data`)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to migrate legacy store', error)
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistoryFromDatabase(): void {
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const rows = database.select().from(downloadHistoryTable).all()
|
||||
this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to load rows', error)
|
||||
this.history = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {
|
||||
const fallbackTimestamp = Date.now()
|
||||
const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp
|
||||
const status = item.status ?? 'pending'
|
||||
|
||||
return {
|
||||
...item,
|
||||
status,
|
||||
downloadedAt
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
type: item.type,
|
||||
status: item.status,
|
||||
downloadPath: item.downloadPath ?? null,
|
||||
savedFileName: item.savedFileName ?? null,
|
||||
fileSize: item.fileSize ?? null,
|
||||
duration: item.duration ?? null,
|
||||
downloadedAt: item.downloadedAt,
|
||||
completedAt: item.completedAt ?? null,
|
||||
sortKey: item.completedAt ?? item.downloadedAt,
|
||||
error: item.error ?? null,
|
||||
description: item.description ?? null,
|
||||
channel: item.channel ?? null,
|
||||
uploader: item.uploader ?? null,
|
||||
viewCount: item.viewCount ?? null,
|
||||
tags: serializeTags(item.tags) ?? null,
|
||||
origin: item.origin ?? null,
|
||||
subscriptionId: item.subscriptionId ?? null,
|
||||
selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,
|
||||
playlistId: item.playlistId ?? null,
|
||||
playlistTitle: item.playlistTitle ?? null,
|
||||
playlistIndex: item.playlistIndex ?? null,
|
||||
playlistSize: item.playlistSize ?? null
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {
|
||||
const { id: _id, ...rest } = payload
|
||||
return rest
|
||||
}
|
||||
|
||||
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
|
||||
let selectedFormat: DownloadHistoryItem['selectedFormat']
|
||||
if (row.selectedFormat) {
|
||||
try {
|
||||
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
|
||||
} catch (error) {
|
||||
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
const tags = parseTags(row.tags ?? null)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
type: row.type as DownloadHistoryItem['type'],
|
||||
status: row.status as DownloadHistoryItem['status'],
|
||||
downloadPath: row.downloadPath ?? undefined,
|
||||
savedFileName: row.savedFileName ?? undefined,
|
||||
fileSize: row.fileSize ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
viewCount: row.viewCount ?? undefined,
|
||||
tags,
|
||||
origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,
|
||||
subscriptionId: row.subscriptionId ?? undefined,
|
||||
selectedFormat,
|
||||
playlistId: row.playlistId ?? undefined,
|
||||
playlistTitle: row.playlistTitle ?? undefined,
|
||||
playlistIndex: row.playlistIndex ?? undefined,
|
||||
playlistSize: row.playlistSize ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
addHistoryItem(item: DownloadHistoryItem): void {
|
||||
this.history.set(item.id, item)
|
||||
this.saveHistory()
|
||||
const normalized = this.normalizeItem(item)
|
||||
const insertPayload = this.mapItemToInsert(normalized)
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
database
|
||||
.insert(downloadHistoryTable)
|
||||
.values(insertPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: downloadHistoryTable.id,
|
||||
set: this.mapItemToUpdate(insertPayload)
|
||||
})
|
||||
.run()
|
||||
this.history.set(normalized.id, normalized)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to upsert item', { id: normalized.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
getHistory(): DownloadHistoryItem[] {
|
||||
return Array.from(this.history.values()).sort((a, b) => {
|
||||
const aTime = a.completedAt || a.downloadedAt
|
||||
const bTime = b.completedAt || b.downloadedAt
|
||||
const aTime = a.completedAt ?? a.downloadedAt
|
||||
const bTime = b.completedAt ?? b.downloadedAt
|
||||
return bTime - aTime
|
||||
})
|
||||
}
|
||||
@@ -56,30 +480,107 @@ class HistoryManager {
|
||||
}
|
||||
|
||||
removeHistoryItem(id: string): boolean {
|
||||
const deleted = this.history.delete(id)
|
||||
if (deleted) {
|
||||
this.saveHistory()
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.id, id))
|
||||
.run()
|
||||
const removedFromMap = this.history.delete(id)
|
||||
return result.changes > 0 || removedFromMap
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete item', { id, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
removeHistoryItems(ids: string[]): number {
|
||||
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
|
||||
if (uniqueIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let removedCount = 0
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(inArray(downloadHistoryTable.id, uniqueIds))
|
||||
.run()
|
||||
for (const id of uniqueIds) {
|
||||
if (this.history.delete(id)) {
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
removeHistoryByPlaylistId(playlistId: string): number {
|
||||
const normalized = playlistId.trim()
|
||||
if (!normalized) {
|
||||
return 0
|
||||
}
|
||||
let removedCount = 0
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.playlistId, normalized))
|
||||
.run()
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.playlistId === normalized) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
|
||||
return removedCount
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.history.clear()
|
||||
this.saveHistory()
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
database.delete(downloadHistoryTable).run()
|
||||
this.history.clear()
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to clear items', error)
|
||||
}
|
||||
}
|
||||
|
||||
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
|
||||
let removedCount = 0
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.status === status) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.status, status))
|
||||
.run()
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.status === status) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to clear items by status', { status, error })
|
||||
return removedCount
|
||||
}
|
||||
if (removedCount > 0) {
|
||||
this.saveHistory()
|
||||
}
|
||||
return removedCount
|
||||
}
|
||||
|
||||
getHistoryCount(): {
|
||||
@@ -111,6 +612,15 @@ class HistoryManager {
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
hasHistoryForUrl(url: string): boolean {
|
||||
for (const item of this.history.values()) {
|
||||
if (item.url === url) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const historyManager = new HistoryManager()
|
||||
|
||||
643
src/main/lib/subscription-manager.ts
Normal file
643
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import log from 'electron-log/main'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionFeedItem,
|
||||
SubscriptionRule,
|
||||
SubscriptionStatus,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
|
||||
import { runMigrations } from './database/migrate'
|
||||
import {
|
||||
type SubscriptionInsert,
|
||||
type SubscriptionItemRow,
|
||||
type SubscriptionRow,
|
||||
subscriptionItemsTable,
|
||||
subscriptionsTable
|
||||
} from './database/schema'
|
||||
import { getDatabaseFilePath } from './database-path'
|
||||
|
||||
const sanitizeList = (values?: string[]): string[] => {
|
||||
if (!values || values.length === 0) {
|
||||
return []
|
||||
}
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
log.error('Failed to ensure subscription directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
|
||||
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
|
||||
|
||||
const parseStringArray = (value: string | null | undefined): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
|
||||
|
||||
export class SubscriptionManager extends EventEmitter {
|
||||
private sqlite: BetterSqlite3Instance | null = null
|
||||
private db: BetterSQLite3Database | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
try {
|
||||
this.getDatabase()
|
||||
this.migrateLegacyStore()
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to initialize database', error)
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): SubscriptionRule[] {
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.orderBy(desc(subscriptionsTable.updatedAt))
|
||||
.all()
|
||||
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
|
||||
}
|
||||
|
||||
getById(id: string): SubscriptionRule | undefined {
|
||||
const database = this.getDatabase()
|
||||
const row = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.where(eq(subscriptionsTable.id, id))
|
||||
.get()
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||
}
|
||||
|
||||
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||
const timestamp = Date.now()
|
||||
const keywords = sanitizeList(payload.keywords)
|
||||
const tags = sanitizeList(payload.tags)
|
||||
const record: SubscriptionRule = {
|
||||
id: randomUUID(),
|
||||
title: payload.sourceUrl,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
feedUrl: payload.feedUrl,
|
||||
platform: payload.platform,
|
||||
keywords,
|
||||
tags,
|
||||
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
|
||||
enabled: payload.enabled ?? true,
|
||||
coverUrl: undefined,
|
||||
latestVideoTitle: undefined,
|
||||
latestVideoPublishedAt: undefined,
|
||||
lastCheckedAt: undefined,
|
||||
lastSuccessAt: undefined,
|
||||
status: 'idle',
|
||||
lastError: undefined,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
downloadDirectory: payload.downloadDirectory,
|
||||
namingTemplate: payload.namingTemplate
|
||||
? sanitizeFilenameTemplate(payload.namingTemplate)
|
||||
: undefined,
|
||||
items: []
|
||||
}
|
||||
|
||||
ensureDirectoryExists(payload.downloadDirectory)
|
||||
this.insertRecord(record)
|
||||
this.emitUpdates()
|
||||
return record
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
|
||||
): SubscriptionRule | undefined {
|
||||
const existing = this.getById(id)
|
||||
if (!existing) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
|
||||
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
|
||||
const next: SubscriptionRule = {
|
||||
...existing,
|
||||
...updates,
|
||||
keywords: keywords ?? existing.keywords,
|
||||
tags: tags ?? existing.tags,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
|
||||
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
|
||||
next.title = updates.sourceUrl
|
||||
}
|
||||
|
||||
if (updates.namingTemplate) {
|
||||
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
|
||||
}
|
||||
|
||||
ensureDirectoryExists(next.downloadDirectory)
|
||||
this.updateRecord(next)
|
||||
this.emitUpdates()
|
||||
return next
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const database = this.getDatabase()
|
||||
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
database
|
||||
.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, id))
|
||||
.run()
|
||||
this.emitUpdates()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
replaceFeedItems(
|
||||
subscriptionId: string,
|
||||
items: SubscriptionFeedItem[],
|
||||
silent: boolean = false
|
||||
): void {
|
||||
const database = this.getDatabase()
|
||||
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
const now = Date.now()
|
||||
database.transaction((tx) => {
|
||||
tx.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
|
||||
.run()
|
||||
for (const item of orderedItems) {
|
||||
tx.insert(subscriptionItemsTable)
|
||||
.values({
|
||||
subscriptionId,
|
||||
itemId: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
added: booleanToNumber(item.addedToQueue),
|
||||
downloadId: item.downloadId ?? null,
|
||||
createdAt: item.publishedAt,
|
||||
updatedAt: now
|
||||
})
|
||||
.run()
|
||||
}
|
||||
})
|
||||
if (!silent) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
updateFeedItemQueueState(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
updates: { added?: boolean; downloadId?: string | null }
|
||||
): void {
|
||||
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
|
||||
return
|
||||
}
|
||||
|
||||
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
if (updates.added !== undefined) {
|
||||
setPayload.added = booleanToNumber(updates.added)
|
||||
}
|
||||
if (Object.hasOwn(updates, 'downloadId')) {
|
||||
setPayload.downloadId = updates.downloadId ?? null
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.update(subscriptionItemsTable)
|
||||
.set(setPayload)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
|
||||
eq(subscriptionItemsTable.itemId, itemId)
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
|
||||
if (records.length === 0) {
|
||||
return records
|
||||
}
|
||||
const ids = records.map((record) => record.id)
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionItemsTable)
|
||||
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
|
||||
.orderBy(desc(subscriptionItemsTable.publishedAt))
|
||||
.all()
|
||||
|
||||
const grouped = new Map<string, SubscriptionFeedItem[]>()
|
||||
for (const row of rows) {
|
||||
const item = this.mapItemRowToFeedItem(row)
|
||||
const list = grouped.get(row.subscriptionId)
|
||||
if (list) {
|
||||
list.push(item)
|
||||
} else {
|
||||
grouped.set(row.subscriptionId, [item])
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
items: grouped.get(record.id) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
private getDatabase(): BetterSQLite3Database {
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
this.sqlite.pragma('journal_mode = WAL')
|
||||
this.sqlite.pragma('foreign_keys = ON')
|
||||
this.db = drizzle(this.sqlite)
|
||||
runMigrations(this.db)
|
||||
this.ensureSubscriptionsSchema()
|
||||
this.ensureItemsSchema()
|
||||
log.info('subscriptions: database initialized at', databasePath)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private ensureItemsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasAdded = columns.some((column) => column.name === 'added')
|
||||
if (!hasAdded) {
|
||||
this.sqlite
|
||||
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
|
||||
.run()
|
||||
}
|
||||
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
|
||||
const hasLegacyStatus = columns.some((column) => column.name === 'status')
|
||||
const needsMigration = hasDownloaded || hasLegacyStatus
|
||||
if (needsMigration) {
|
||||
if (hasDownloaded) {
|
||||
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscription_items_new (
|
||||
subscription_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
published_at INTEGER NOT NULL,
|
||||
thumbnail TEXT,
|
||||
added INTEGER NOT NULL,
|
||||
download_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (subscription_id, item_id)
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscription_items_new (
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM subscription_items`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscription_items`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
|
||||
)
|
||||
.run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy columns from subscription_items')
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscription_items schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSubscriptionsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasLegacyColumns = columns.some((column) =>
|
||||
['seen_item_ids', 'last_item_id'].includes(column.name)
|
||||
)
|
||||
if (!hasLegacyColumns) {
|
||||
return
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscriptions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
keywords TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
only_latest INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
cover_url TEXT,
|
||||
latest_video_title TEXT,
|
||||
latest_video_published_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
last_success_at INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
download_directory TEXT,
|
||||
naming_template TEXT
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscriptions_new (
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
FROM subscriptions`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscriptions`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscriptions schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private migrateLegacyStore(): void {
|
||||
try {
|
||||
const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const LegacyStore = ElectronStore.default || ElectronStore
|
||||
const store = new LegacyStore({
|
||||
name: 'subscriptions',
|
||||
defaults: {
|
||||
subscriptions: []
|
||||
}
|
||||
})
|
||||
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
|
||||
if (!legacyData || legacyData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.getAll().length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const legacyItem of legacyData) {
|
||||
try {
|
||||
const normalized: SubscriptionRule = {
|
||||
...legacyItem,
|
||||
keywords: sanitizeList(legacyItem.keywords),
|
||||
tags: sanitizeList(legacyItem.tags),
|
||||
items: []
|
||||
}
|
||||
this.insertRecord(normalized)
|
||||
const legacyFeedItemsRaw = Array.isArray(
|
||||
(legacyItem as { items?: SubscriptionFeedItem[] }).items
|
||||
)
|
||||
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
|
||||
: []
|
||||
if (legacyFeedItemsRaw.length > 0) {
|
||||
const converted = legacyFeedItemsRaw.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
|
||||
downloadId: item.downloadId
|
||||
}))
|
||||
this.replaceFeedItems(normalized.id, converted, true)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to migrate legacy record', error)
|
||||
}
|
||||
}
|
||||
|
||||
store.clear()
|
||||
this.emitUpdates()
|
||||
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: legacy migration skipped', error)
|
||||
}
|
||||
}
|
||||
|
||||
private insertRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private updateRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
sourceUrl: record.sourceUrl,
|
||||
feedUrl: record.feedUrl,
|
||||
platform: record.platform,
|
||||
keywords: stringifyArray(record.keywords),
|
||||
tags: stringifyArray(record.tags),
|
||||
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
|
||||
enabled: booleanToNumber(record.enabled),
|
||||
coverUrl: record.coverUrl,
|
||||
latestVideoTitle: record.latestVideoTitle,
|
||||
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
|
||||
lastCheckedAt: record.lastCheckedAt ?? null,
|
||||
lastSuccessAt: record.lastSuccessAt ?? null,
|
||||
status: record.status,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
downloadDirectory: record.downloadDirectory,
|
||||
namingTemplate: record.namingTemplate
|
||||
? sanitizeFilenameTemplate(record.namingTemplate)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
sourceUrl: row.sourceUrl,
|
||||
feedUrl: row.feedUrl,
|
||||
platform: row.platform as SubscriptionRule['platform'],
|
||||
keywords: parseStringArray(row.keywords),
|
||||
tags: parseStringArray(row.tags),
|
||||
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
|
||||
enabled: numberToBoolean(row.enabled),
|
||||
coverUrl: row.coverUrl ?? undefined,
|
||||
latestVideoTitle: row.latestVideoTitle ?? undefined,
|
||||
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
|
||||
lastCheckedAt: row.lastCheckedAt ?? undefined,
|
||||
lastSuccessAt: row.lastSuccessAt ?? undefined,
|
||||
status: row.status as SubscriptionStatus,
|
||||
lastError: row.lastError ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
downloadDirectory: row.downloadDirectory ?? undefined,
|
||||
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
|
||||
return {
|
||||
id: row.itemId,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
publishedAt: row.publishedAt,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
addedToQueue: numberToBoolean(row.added),
|
||||
downloadId: row.downloadId ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdates(): void {
|
||||
this.emit('subscriptions:updated', this.getAll())
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionManager = new SubscriptionManager()
|
||||
459
src/main/lib/subscription-scheduler.ts
Normal file
459
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import log from 'electron-log/main'
|
||||
import Parser from 'rss-parser'
|
||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
|
||||
import { settingsManager } from '../settings'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { historyManager } from './history-manager'
|
||||
import { subscriptionManager } from './subscription-manager'
|
||||
|
||||
const logger = log.scope('subscriptions')
|
||||
|
||||
type ParserItem = {
|
||||
title?: string
|
||||
link?: string
|
||||
guid?: string
|
||||
id?: string
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TrackedDownload = {
|
||||
subscriptionId: string
|
||||
itemId: string
|
||||
url: string
|
||||
retries: number
|
||||
downloadId: string
|
||||
}
|
||||
|
||||
type FeedItem = {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||
return `sub_${base}`
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to ensure subscription download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionScheduler extends EventEmitter {
|
||||
private timer?: NodeJS.Timeout
|
||||
private checking = false
|
||||
private pendingRun = false
|
||||
private downloads: Map<string, TrackedDownload> = new Map()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
downloadEngine.on('download-completed', (id: string) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: id
|
||||
})
|
||||
})
|
||||
|
||||
downloadEngine.on('download-error', (id: string, error: Error) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
const currentRetries = tracked.retries ?? 0
|
||||
if (currentRetries < 1) {
|
||||
logger.warn('Retrying failed subscription download', { id, error })
|
||||
this.queueDownload(
|
||||
tracked.subscriptionId,
|
||||
tracked.itemId,
|
||||
tracked.url,
|
||||
currentRetries + 1
|
||||
).catch((queueError) => {
|
||||
logger.error('Retry queue failed:', queueError)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'failed',
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.scheduleNextRun(0)
|
||||
}
|
||||
|
||||
refreshInterval(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.scheduleNextRun()
|
||||
}
|
||||
|
||||
async runNow(subscriptionId?: string): Promise<void> {
|
||||
if (subscriptionId) {
|
||||
const target = subscriptionManager.getById(subscriptionId)
|
||||
if (target?.enabled) {
|
||||
await this.checkSubscription(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.checkAll()
|
||||
}
|
||||
|
||||
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return false
|
||||
}
|
||||
const item = subscription.items.find((entry) => entry.id === itemId)
|
||||
if (!item || item.addedToQueue) {
|
||||
return false
|
||||
}
|
||||
await this.queueDownload(subscriptionId, itemId, item.url)
|
||||
return true
|
||||
}
|
||||
|
||||
private scheduleNextRun(initialDelay?: number): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||
this.timer = setTimeout(() => {
|
||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async checkAll(): Promise<void> {
|
||||
if (this.checking) {
|
||||
this.pendingRun = true
|
||||
return
|
||||
}
|
||||
this.checking = true
|
||||
try {
|
||||
const subscriptions = subscriptionManager
|
||||
.getAll()
|
||||
.filter((subscription) => subscription.enabled)
|
||||
for (const subscription of subscriptions) {
|
||||
await this.checkSubscription(subscription)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to run subscription sync', error)
|
||||
} finally {
|
||||
this.checking = false
|
||||
if (this.pendingRun) {
|
||||
this.pendingRun = false
|
||||
void this.checkAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'checking',
|
||||
lastCheckedAt: startedAt,
|
||||
lastError: undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const feed = await parser.parseURL(subscription.feedUrl)
|
||||
const feedItems = Array.isArray(feed.items) ? feed.items : []
|
||||
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
|
||||
const recentItems = this.filterRecentItems(subscription, normalizedItems)
|
||||
const unseenItems = this.filterNewItems(subscription, recentItems)
|
||||
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
|
||||
const keywordFiltered =
|
||||
keywords.length > 0
|
||||
? unseenItems.filter((item) => {
|
||||
const lowered = item.title.toLowerCase()
|
||||
return keywords.some((keyword) => lowered.includes(keyword))
|
||||
})
|
||||
: unseenItems
|
||||
|
||||
const deduped = keywordFiltered
|
||||
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
|
||||
const itemsToDownload =
|
||||
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
|
||||
|
||||
subscriptionManager.replaceFeedItems(
|
||||
subscription.id,
|
||||
this.buildFeedItems(normalizedItems, subscription)
|
||||
)
|
||||
|
||||
if (itemsToDownload.length > 0) {
|
||||
for (const item of itemsToDownload) {
|
||||
await this.queueDownload(subscription.id, item.id, item.url)
|
||||
}
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
: subscription.title,
|
||||
sourceUrl:
|
||||
typeof feed.link === 'string' && feed.link.trim().length > 0
|
||||
? feed.link.trim()
|
||||
: subscription.sourceUrl
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown RSS error'
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'failed',
|
||||
lastError: message,
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
logger.error('Subscription check failed:', { id: subscription.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
|
||||
const normalized: FeedItem[] = []
|
||||
for (const item of items) {
|
||||
const id = this.resolveItemId(item)
|
||||
if (!id || !item.link || !item.title) {
|
||||
continue
|
||||
}
|
||||
normalized.push({
|
||||
id,
|
||||
url: item.link,
|
||||
title: item.title,
|
||||
publishedAt: this.resolvePublishedAt(item),
|
||||
thumbnail: this.resolveThumbnail(item)
|
||||
})
|
||||
}
|
||||
|
||||
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
}
|
||||
|
||||
private buildFeedItems(
|
||||
items: FeedItem[],
|
||||
subscription: SubscriptionRule
|
||||
): SubscriptionFeedItem[] {
|
||||
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
|
||||
return items.map((item) => {
|
||||
const tracked = this.getTrackedDownloadByUrl(item.url)
|
||||
const existing = existingItems.get(item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue:
|
||||
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
|
||||
downloadId: tracked?.downloadId ?? existing?.downloadId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private resolveItemId(item: ParserItem): string | null {
|
||||
const idCandidate =
|
||||
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
|
||||
if (!idCandidate) {
|
||||
return null
|
||||
}
|
||||
return idCandidate.trim()
|
||||
}
|
||||
|
||||
private resolvePublishedAt(item: ParserItem): number {
|
||||
const candidates = [item.isoDate, item.pubDate]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const timestamp = Date.parse(candidate)
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
private resolveThumbnail(item: ParserItem): string | undefined {
|
||||
// Try media:thumbnail first
|
||||
const thumbnail = item.mediaThumbnail
|
||||
if (Array.isArray(thumbnail)) {
|
||||
const found = thumbnail.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
|
||||
return thumbnail.url as string | undefined
|
||||
}
|
||||
|
||||
// Try enclosure (for RSS feeds with image/jpeg type)
|
||||
const enclosure = item.enclosure
|
||||
if (Array.isArray(enclosure)) {
|
||||
const imageEnclosure = enclosure.find(
|
||||
(entry) => entry?.url && entry?.type?.startsWith('image/')
|
||||
)
|
||||
if (imageEnclosure?.url) return imageEnclosure.url
|
||||
}
|
||||
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
|
||||
const enc = enclosure as { url?: string; type?: string }
|
||||
if (enc.url && enc.type?.startsWith('image/')) {
|
||||
return enc.url
|
||||
}
|
||||
}
|
||||
|
||||
// Try media:content as fallback
|
||||
const mediaContent = item.mediaContent
|
||||
if (Array.isArray(mediaContent)) {
|
||||
const found = mediaContent.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
|
||||
if (lastKnownPublishedAt === 0) {
|
||||
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
|
||||
}
|
||||
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
|
||||
}
|
||||
|
||||
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
|
||||
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
|
||||
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
|
||||
}
|
||||
|
||||
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const seenIds = new Set(subscription.items.map((item) => item.id))
|
||||
return items.filter((item) => !seenIds.has(item.id))
|
||||
}
|
||||
|
||||
private async queueDownload(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
url: string,
|
||||
retryCount = 0
|
||||
): Promise<void> {
|
||||
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
|
||||
const isRetry = retryCount > 0
|
||||
if (this.downloads.has(downloadId) && !isRetry) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||
const namingTemplate =
|
||||
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
customDownloadPath: downloadDirectory,
|
||||
customFilenameTemplate: namingTemplate,
|
||||
tags,
|
||||
origin: 'subscription',
|
||||
subscriptionId
|
||||
})
|
||||
|
||||
this.downloads.set(downloadId, {
|
||||
subscriptionId,
|
||||
itemId,
|
||||
url,
|
||||
retries: retryCount,
|
||||
downloadId
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: true,
|
||||
downloadId
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
|
||||
subscriptionManager.update(subscriptionId, {
|
||||
status: 'failed'
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: false,
|
||||
downloadId: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
|
||||
for (const tracked of this.downloads.values()) {
|
||||
if (tracked.url === url) {
|
||||
return tracked
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionScheduler = new SubscriptionScheduler()
|
||||
@@ -2,8 +2,9 @@ import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { app } from 'electron'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
|
||||
@@ -37,7 +38,11 @@ export class ThumbnailCache {
|
||||
return null
|
||||
}
|
||||
|
||||
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
|
||||
if (
|
||||
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||
originalUrl.startsWith('file://') ||
|
||||
originalUrl.startsWith('data:')
|
||||
) {
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
@@ -59,7 +64,7 @@ export class ThumbnailCache {
|
||||
|
||||
const existingPath = await this.findExistingPath(basePath, defaultExtension)
|
||||
if (existingPath) {
|
||||
return pathToFileURL(existingPath).toString()
|
||||
return this.toAppProtocolUrl(existingPath)
|
||||
}
|
||||
|
||||
const response = await fetch(originalUrl)
|
||||
@@ -75,9 +80,9 @@ export class ThumbnailCache {
|
||||
const finalPath = `${basePath}${extension}`
|
||||
|
||||
await fsPromises.writeFile(finalPath, buffer)
|
||||
return pathToFileURL(finalPath).toString()
|
||||
return this.toAppProtocolUrl(finalPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to cache thumbnail:', error)
|
||||
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -132,6 +137,13 @@ export class ThumbnailCache {
|
||||
const basePath = path.join(cacheDir, `${hash}`)
|
||||
return { basePath, defaultExtension: extension }
|
||||
}
|
||||
|
||||
private toAppProtocolUrl(filePath: string): string {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const relativePath = path.relative(userDataPath, filePath).replace(/\\/g, '/')
|
||||
|
||||
return `${APP_PROTOCOL_SCHEME}${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
export const thumbnailCache = new ThumbnailCache()
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type YTDlpWrap from 'yt-dlp-wrap-plus'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
|
||||
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
||||
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
|
||||
class YtDlpManager {
|
||||
private ytdlpPath: string | null = null
|
||||
private ytdlpInstance: YTDlpWrapInstance | null = null
|
||||
private jsRuntimeArgs: string[] = []
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
||||
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
|
||||
console.log('yt-dlp initialized at:', this.ytdlpPath)
|
||||
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
|
||||
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
|
||||
}
|
||||
|
||||
getInstance(): YTDlpWrapInstance {
|
||||
@@ -33,6 +36,10 @@ class YtDlpManager {
|
||||
return this.ytdlpPath
|
||||
}
|
||||
|
||||
getJsRuntimeArgs(): string[] {
|
||||
return [...this.jsRuntimeArgs]
|
||||
}
|
||||
|
||||
private getResourcesPath(): string {
|
||||
// In development, read from project root's resources
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -57,7 +64,7 @@ class YtDlpManager {
|
||||
|
||||
// Check environment variable first
|
||||
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
||||
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
||||
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
||||
return process.env.YTDLP_PATH
|
||||
}
|
||||
|
||||
@@ -65,13 +72,13 @@ class YtDlpManager {
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
const bundledPath = path.join(resourcesPath, bundledName)
|
||||
if (fs.existsSync(bundledPath)) {
|
||||
console.log('Using bundled yt-dlp:', bundledPath)
|
||||
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
|
||||
// Make executable on Unix-like systems if needed
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(bundledPath, 0o755)
|
||||
} catch (error) {
|
||||
console.warn('Failed to set executable permission:', error)
|
||||
scopedLoggers.engine.warn('Failed to set executable permission:', error)
|
||||
}
|
||||
}
|
||||
return bundledPath
|
||||
@@ -82,7 +89,7 @@ class YtDlpManager {
|
||||
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
console.log('Using system yt-dlp:', p)
|
||||
scopedLoggers.engine.info('Using system yt-dlp:', p)
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -93,7 +100,7 @@ class YtDlpManager {
|
||||
try {
|
||||
const systemPath = execSync('which yt-dlp').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
console.log('Using system yt-dlp:', systemPath)
|
||||
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -115,6 +122,92 @@ class YtDlpManager {
|
||||
)
|
||||
}
|
||||
|
||||
private resolveJsRuntimeArgs(): string[] {
|
||||
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
|
||||
if (!runtime || runtime === 'none') {
|
||||
return []
|
||||
}
|
||||
|
||||
const runtimePath = this.resolveJsRuntimePath(runtime)
|
||||
if (runtimePath) {
|
||||
return ['--js-runtimes', `${runtime}:${runtimePath}`]
|
||||
}
|
||||
|
||||
if (process.env.YTDLP_JS_RUNTIME) {
|
||||
scopedLoggers.engine.warn(
|
||||
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
|
||||
)
|
||||
} else {
|
||||
scopedLoggers.engine.warn(
|
||||
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
|
||||
)
|
||||
}
|
||||
|
||||
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
|
||||
}
|
||||
|
||||
private resolveJsRuntimePath(runtime: string): string | null {
|
||||
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
|
||||
return envPath
|
||||
}
|
||||
|
||||
const platform = os.platform()
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
const resourceCandidates: string[] = []
|
||||
|
||||
if (runtime === 'deno') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
|
||||
} else if (runtime === 'node') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
|
||||
} else if (runtime === 'bun') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
} else if (runtime === 'quickjs') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
|
||||
} else {
|
||||
resourceCandidates.push(runtime)
|
||||
if (platform === 'win32' && !runtime.endsWith('.exe')) {
|
||||
resourceCandidates.push(`${runtime}.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of resourceCandidates) {
|
||||
const fullPath = path.join(resourcesPath, candidate)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
|
||||
}
|
||||
}
|
||||
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
scopedLoggers.engine.info('Using system JS runtime:', output)
|
||||
return output
|
||||
}
|
||||
} else {
|
||||
const systemPath = execSync(`which ${runtime}`).toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Runtime not found in PATH
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Removed runtime download/update to avoid network dependency in production builds
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AppSettings } from '../shared/types'
|
||||
import { defaultSettings } from '../shared/types'
|
||||
import { scopedLoggers } from './utils/logger'
|
||||
|
||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||
const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
||||
const ensureDirectoryExists = (dir: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
|
||||
class SettingsManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
@@ -16,9 +35,10 @@ class SettingsManager {
|
||||
this.store = new Store({
|
||||
defaults: {
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
}
|
||||
})
|
||||
this.ensureDownloadDirectory()
|
||||
}
|
||||
|
||||
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||
@@ -26,6 +46,9 @@ class SettingsManager {
|
||||
}
|
||||
|
||||
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
@@ -35,6 +58,9 @@ class SettingsManager {
|
||||
|
||||
setAll(settings: Partial<AppSettings>): void {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
|
||||
}
|
||||
}
|
||||
@@ -43,8 +69,25 @@ class SettingsManager {
|
||||
this.store.clear()
|
||||
this.store.set({
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
try {
|
||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||
const normalizedDownloadPath =
|
||||
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
|
||||
? DEFAULT_DOWNLOAD_PATH
|
||||
: currentPath
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { normalizeLanguageCode } from '@shared/languages'
|
||||
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
|
||||
import appIcon from '../../resources/icon.png?asset'
|
||||
import trayIcon from '../../resources/tray-icon.png?asset'
|
||||
import { settingsManager } from './settings'
|
||||
|
||||
let tray: Tray | null = null
|
||||
@@ -8,7 +10,7 @@ let tray: Tray | null = null
|
||||
* Get translated text based on current language setting
|
||||
*/
|
||||
function t(key: 'showHome' | 'quit'): string {
|
||||
const language = settingsManager.get('language') || 'en'
|
||||
const language = normalizeLanguageCode(settingsManager.get('language'))
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
@@ -16,12 +18,12 @@ function t(key: 'showHome' | 'quit'): string {
|
||||
quit: 'Quit'
|
||||
},
|
||||
zh: {
|
||||
showHome: '打开首页',
|
||||
quit: '关闭应用'
|
||||
showHome: '显示主页',
|
||||
quit: '退出应用'
|
||||
}
|
||||
}
|
||||
|
||||
const displayLang = language.startsWith('en') ? 'en' : 'zh'
|
||||
const displayLang = language.startsWith('zh') ? 'zh' : 'en'
|
||||
return translations[displayLang][key]
|
||||
}
|
||||
|
||||
@@ -85,9 +87,17 @@ export function createTray(): void {
|
||||
return
|
||||
}
|
||||
|
||||
const trayIcon = nativeImage.createFromPath(appIcon)
|
||||
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
|
||||
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
|
||||
const trayIconImage = nativeImage.createFromPath(iconPath)
|
||||
|
||||
tray = new Tray(trayIcon)
|
||||
// For macOS, ensure the icon is properly sized
|
||||
if (process.platform === 'darwin') {
|
||||
// Resize to 16x16 for macOS tray
|
||||
trayIconImage.setTemplateImage(true)
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconImage)
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('VidBee')
|
||||
|
||||
38
src/main/utils/auto-launch.ts
Normal file
38
src/main/utils/auto-launch.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])
|
||||
|
||||
export function isAutoLaunchSupported(): boolean {
|
||||
return SUPPORTED_PLATFORMS.has(process.platform)
|
||||
}
|
||||
|
||||
export function applyAutoLaunchSetting(enabled: boolean): void {
|
||||
if (!isAutoLaunchSupported()) {
|
||||
log.info('Auto launch is not supported on this platform, skipping setting update')
|
||||
return
|
||||
}
|
||||
|
||||
const updateSetting = () => {
|
||||
try {
|
||||
const options: Parameters<typeof app.setLoginItemSettings>[0] = {
|
||||
openAtLogin: enabled
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
options.openAsHidden = true
|
||||
}
|
||||
|
||||
app.setLoginItemSettings(options)
|
||||
log.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
log.error('Failed to update login item settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (app.isReady()) {
|
||||
updateSetting()
|
||||
} else {
|
||||
app.once('ready', updateSetting)
|
||||
}
|
||||
}
|
||||
16
src/main/utils/dock.ts
Normal file
16
src/main/utils/dock.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Apply Dock visibility preference on macOS.
|
||||
*/
|
||||
export function applyDockVisibility(hideDockIcon: boolean): void {
|
||||
if (process.platform !== 'darwin' || !app.dock) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hideDockIcon) {
|
||||
app.dock.hide()
|
||||
} else {
|
||||
app.dock.show()
|
||||
}
|
||||
}
|
||||
19
src/main/utils/path-helpers.ts
Normal file
19
src/main/utils/path-helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
export const resolvePathWithHome = (rawPath?: string | null): string | undefined => {
|
||||
const trimmed = rawPath?.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (trimmed === '~') {
|
||||
return os.homedir()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2))
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>VidBee</title>
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -2,49 +2,263 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import { Sidebar } from '@renderer/components/ui/sidebar'
|
||||
import { Toaster } from '@renderer/components/ui/sonner'
|
||||
import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { SupportedSites } from './pages/SupportedSites'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||
|
||||
const pageToPath: Record<Page, string> = {
|
||||
home: '/',
|
||||
subscriptions: '/subscriptions',
|
||||
settings: '/settings',
|
||||
about: '/about'
|
||||
}
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
const trimmed = pathname.replace(/\/+$/, '')
|
||||
return trimmed === '' ? '/' : trimmed
|
||||
}
|
||||
|
||||
const pathToPage = (pathname: string): Page => {
|
||||
const normalized = normalizePathname(pathname)
|
||||
switch (normalized) {
|
||||
case '/subscriptions':
|
||||
return 'subscriptions'
|
||||
case '/settings':
|
||||
return 'settings'
|
||||
case '/about':
|
||||
return 'about'
|
||||
default:
|
||||
return 'home'
|
||||
}
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const { t } = useTranslation()
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const currentPage = pathToPage(location.pathname)
|
||||
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
return <SupportedSites />
|
||||
default:
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
}
|
||||
const handlePageChange = useCallback(
|
||||
(page: Page) => {
|
||||
const targetPath = pageToPath[page] ?? '/'
|
||||
if (normalizePathname(location.pathname) !== targetPath) {
|
||||
navigate(targetPath)
|
||||
}
|
||||
},
|
||||
[location.pathname, navigate]
|
||||
)
|
||||
|
||||
const handleOpenSupportedSites = () => {
|
||||
window.open(supportedSitesUrl, '_blank')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = (rawUrl: unknown) => {
|
||||
const url = typeof rawUrl === 'string' ? rawUrl.trim() : ''
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
// Switch to home page to show download dialog
|
||||
handlePageChange('home')
|
||||
// The DownloadDialog component will handle opening the dialog and parsing the video
|
||||
}
|
||||
|
||||
ipcEvents.on('download:deeplink', handleDeepLink)
|
||||
return () => {
|
||||
ipcEvents.removeListener('download:deeplink', handleDeepLink)
|
||||
}
|
||||
}, [handlePageChange])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptions()
|
||||
|
||||
const handleSubscriptions = (...args: unknown[]) => {
|
||||
const list = args[0]
|
||||
if (Array.isArray(list)) {
|
||||
setSubscriptions(list as SubscriptionRule[])
|
||||
}
|
||||
}
|
||||
|
||||
ipcEvents.on('subscriptions:updated', handleSubscriptions)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)
|
||||
}
|
||||
}, [loadSubscriptions, setSubscriptions])
|
||||
|
||||
// Load or remove analytics script based on settings
|
||||
useEffect(() => {
|
||||
const scriptId = 'analytics-script'
|
||||
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
||||
|
||||
if (settings.enableAnalytics) {
|
||||
// Remove existing script if it exists
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
}
|
||||
|
||||
// Create and append new script
|
||||
const script = document.createElement('script')
|
||||
script.id = scriptId
|
||||
script.src = 'https://rybbit.102417.xyz/api/script.js'
|
||||
script.setAttribute('data-site-id', '7bc6f6d625a4')
|
||||
script.defer = true
|
||||
script.async = true
|
||||
document.head.appendChild(script)
|
||||
analyticsScriptRef.current = script
|
||||
} else {
|
||||
// Remove script if analytics is disabled
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
analyticsScriptRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
const script = document.getElementById(scriptId)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
}
|
||||
}, [settings.enableAnalytics])
|
||||
|
||||
useEffect(() => {
|
||||
// Get platform info to determine if we should show title bar
|
||||
const getPlatform = async () => {
|
||||
try {
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
// Default to showing title bar if platform detection fails
|
||||
setPlatform('unknown')
|
||||
}
|
||||
}
|
||||
getPlatform()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window?.api) {
|
||||
return
|
||||
}
|
||||
|
||||
const resetDownloadState = () => {
|
||||
if (updateDownloadInProgressRef.current) {
|
||||
updateDownloadInProgressRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
||||
resetDownloadState()
|
||||
}
|
||||
|
||||
const handleUpdateError = (rawMessage: unknown) => {
|
||||
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
||||
resetDownloadState()
|
||||
|
||||
const errorMessage = message || t('about.notifications.unknownErrorFallback')
|
||||
toast.error(t('about.notifications.updateError', { error: errorMessage }))
|
||||
}
|
||||
|
||||
const handleDownloadProgress = (rawProgress: unknown) => {
|
||||
const progress = (rawProgress ?? {}) as { percent?: number }
|
||||
if (typeof progress?.percent === 'number') {
|
||||
console.info('Update download progress:', progress.percent.toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||
const payload = (rawPayload ?? {}) as { version?: string }
|
||||
const versionLabel = payload?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only listen to update events that should be shown globally
|
||||
// update:available is handled in About page only
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.on('update:error', handleUpdateError)
|
||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||
<Sidebar
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{/* Custom Title Bar */}
|
||||
<TitleBar />
|
||||
<TitleBar platform={platform} />
|
||||
|
||||
<ScrollArea
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
|
||||
{renderPage()}
|
||||
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
@@ -57,7 +271,9 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<AppContent />
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css';
|
||||
@import "tw-animate-css";
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -8,5 +10,9 @@
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--radius: 0.625rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
@@ -60,50 +60,35 @@
|
||||
--card-foreground: oklch(0.8853 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.9328 0.0025 228.7857);
|
||||
--primary: oklch(0.6692 0.1607 245.0110);
|
||||
--primary: oklch(0.8223 0.1704 79.8747);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9622 0.0035 219.5331);
|
||||
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--muted: oklch(0.2090 0 0);
|
||||
--muted: oklch(0.3485 0 0);
|
||||
--muted-foreground: oklch(0.5637 0.0078 247.9662);
|
||||
--accent: oklch(0.1928 0.0331 242.5459);
|
||||
--accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.2674 0.0047 248.0045);
|
||||
--input: oklch(0.3020 0.0288 244.8244);
|
||||
--ring: oklch(0.6818 0.1584 243.3540);
|
||||
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||
--ring: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-1: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||
--sidebar: oklch(0.2097 0.0080 274.5332);
|
||||
--sidebar-foreground: oklch(0.8853 0 0);
|
||||
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
|
||||
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-border: oklch(0.3795 0.0220 240.5943);
|
||||
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0;
|
||||
--shadow-color: #1da1f2;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -145,16 +130,10 @@
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
1379
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
1379
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,31 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@renderer/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import {
|
||||
type DownloadRecord,
|
||||
@@ -16,15 +34,136 @@ import {
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
// Helper function to generate file path with proper path separators
|
||||
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
|
||||
const fileName = `${title}.${format}`
|
||||
// Use proper path joining for cross-platform compatibility
|
||||
return `${downloadPath}/${fileName}`.replace(/\//g, '\\')
|
||||
const normalizeSavedFileName = (fileName?: string): string | undefined => {
|
||||
if (!fileName) {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = fileName.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
|
||||
}
|
||||
|
||||
const generateFilePathCandidates = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
const safeTitle = title.trim() || 'Unknown'
|
||||
|
||||
const savedNameCandidates: string[] = []
|
||||
const trimmedSavedFileName = savedFileName?.trim()
|
||||
if (trimmedSavedFileName) {
|
||||
const normalized = normalizeSavedFileName(trimmedSavedFileName)
|
||||
if (normalized) {
|
||||
savedNameCandidates.push(normalized)
|
||||
}
|
||||
if (!normalized || normalized !== trimmedSavedFileName) {
|
||||
savedNameCandidates.push(trimmedSavedFileName)
|
||||
}
|
||||
}
|
||||
|
||||
const candidateFileNames =
|
||||
savedNameCandidates.length > 0
|
||||
? savedNameCandidates
|
||||
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
|
||||
return Array.from(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
}
|
||||
|
||||
const tryFileOperation = async (
|
||||
paths: string[],
|
||||
operation: (filePath: string) => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
for (const filePath of paths) {
|
||||
const success = await operation(filePath)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||
const normalized = normalizeSavedFileName(fileName)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
if (!normalized.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
const ext = normalized.split('.').pop()
|
||||
return ext?.toLowerCase()
|
||||
}
|
||||
|
||||
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
if (savedExt) {
|
||||
return savedExt
|
||||
}
|
||||
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||
if (selectedExt) {
|
||||
return selectedExt
|
||||
}
|
||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||
}
|
||||
|
||||
const getFormatLabel = (download: DownloadRecord): string | undefined => {
|
||||
if (download.selectedFormat?.ext) {
|
||||
return download.selectedFormat.ext.toUpperCase()
|
||||
}
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
return savedExt ? savedExt.toUpperCase() : undefined
|
||||
}
|
||||
|
||||
const getQualityLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitizeCodec = (codec?: string | null): string | undefined => {
|
||||
if (!codec || codec === 'none') {
|
||||
return undefined
|
||||
}
|
||||
return codec
|
||||
}
|
||||
|
||||
const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (download.type === 'audio' || download.type === 'extract') {
|
||||
return sanitizeCodec(format.acodec)
|
||||
}
|
||||
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
isSelected?: boolean
|
||||
onToggleSelect?: (id: string) => void
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
label: string
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
@@ -50,20 +189,68 @@ const formatDate = (timestamp?: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const formatDateShort = (timestamp?: number) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
const isHistory = download.entryType === 'history'
|
||||
const isSubscriptionDownload = download.origin === 'subscription'
|
||||
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
|
||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||
const actionsContainerBaseClass =
|
||||
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
|
||||
const actionsContainerClass = showActionsWithoutHover
|
||||
? actionsContainerBaseClass
|
||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
||||
const actionsContainerClass =
|
||||
'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
const checkFileExists = async () => {
|
||||
if (!download.title || !download.downloadPath) {
|
||||
setFileExists(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formatForPath = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
download.downloadPath,
|
||||
download.title,
|
||||
formatForPath,
|
||||
download.savedFileName
|
||||
)
|
||||
for (const filePath of filePaths) {
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
if (exists) {
|
||||
setFileExists(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
setFileExists(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
setFileExists(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -77,12 +264,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.openFileLocation(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.openFileLocation(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -91,20 +284,40 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return Boolean(download.title && download.downloadPath && fileExists)
|
||||
}
|
||||
|
||||
// need title, downloadPath, format
|
||||
const handleCopyToClipboard = async () => {
|
||||
if (!download.title || !download.downloadPath || !download.format) {
|
||||
if (!canCopyToClipboard()) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||
const downloadPath = download.downloadPath
|
||||
const format = resolvedExtension
|
||||
const title = download.title
|
||||
|
||||
if (!downloadPath || !title) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath
|
||||
const format = download.format
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.copyFileToClipboard(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.copyFileToClipboard(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
@@ -120,16 +333,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
// Remove from history first
|
||||
await ipcServices.history.removeHistoryItem(download.id)
|
||||
|
||||
// Then try to delete the file
|
||||
await ipcServices.fs.deleteFile(filePath)
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
console.warn('Failed to delete download file for history item:', download.id)
|
||||
}
|
||||
|
||||
removeHistory(download.id)
|
||||
toast.success(t('notifications.itemRemoved'))
|
||||
@@ -178,113 +400,377 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const statusIcon = getStatusIcon()
|
||||
const statusText = getStatusText()
|
||||
const sourceDisplay =
|
||||
download.uploader && download.channel && download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader || download.channel || ''
|
||||
|
||||
const metadataDetails: MetadataDetail[] = []
|
||||
|
||||
if (timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('history.date'),
|
||||
value: formatDate(timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceDisplay) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.source'),
|
||||
value: sourceDisplay
|
||||
})
|
||||
}
|
||||
|
||||
if (download.playlistId) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.playlist'),
|
||||
value: (
|
||||
<span>
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined && download.playlistSize !== undefined ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{` ${t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.duration) {
|
||||
metadataDetails.push({
|
||||
label: t('history.duration'),
|
||||
value: formatDuration(download.duration)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFormatSize =
|
||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||
|
||||
const formatLabelValue = getFormatLabel(download)
|
||||
|
||||
if (formatLabelValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.format'),
|
||||
value: formatLabelValue
|
||||
})
|
||||
}
|
||||
|
||||
const qualityLabel = getQualityLabel(download)
|
||||
|
||||
if (qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.quality'),
|
||||
value: qualityLabel
|
||||
})
|
||||
}
|
||||
|
||||
if (inlineFileSize) {
|
||||
metadataDetails.push({
|
||||
label: t('history.fileSize'),
|
||||
value: inlineFileSize
|
||||
})
|
||||
}
|
||||
|
||||
const codecValue = getCodecLabel(download)
|
||||
if (codecValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.codec'),
|
||||
value: codecValue
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedSavedFileName || download.savedFileName) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.savedFile'),
|
||||
value: normalizedSavedFileName ?? download.savedFileName
|
||||
})
|
||||
}
|
||||
|
||||
if (download.url) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.url'),
|
||||
value: (
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative z-20 wrap-break-word text-primary hover:underline"
|
||||
>
|
||||
{download.url}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Additional metadata fields
|
||||
if (download.description) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.description'),
|
||||
value: <span className="wrap-break-word">{download.description}</span>
|
||||
})
|
||||
}
|
||||
|
||||
if (download.viewCount !== undefined && download.viewCount !== null) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.views'),
|
||||
value: download.viewCount.toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
if (download.tags && download.tags.length > 0) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.tags'),
|
||||
value: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{download.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.downloadPath) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.downloadPath'),
|
||||
value: <span className="wrap-break-word font-mono text-xs">{download.downloadPath}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if (download.createdAt && download.createdAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.createdAt'),
|
||||
value: formatDate(download.createdAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.startedAt) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.startedAt'),
|
||||
value: formatDate(download.startedAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.completedAt && download.completedAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.completedAt'),
|
||||
value: formatDate(download.completedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// Speed
|
||||
if (download.speed) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.speed'),
|
||||
value: download.speed
|
||||
})
|
||||
}
|
||||
|
||||
// File size (if different from inlineFileSize)
|
||||
if (download.fileSize && download.fileSize !== selectedFormatSize) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fileSize'),
|
||||
value: formatFileSize(download.fileSize)
|
||||
})
|
||||
}
|
||||
|
||||
// Selected format details
|
||||
if (download.selectedFormat) {
|
||||
if (download.selectedFormat.width) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.width'),
|
||||
value: `${download.selectedFormat.width}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.height && !qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.height'),
|
||||
value: `${download.selectedFormat.height}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.fps) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fps'),
|
||||
value: `${download.selectedFormat.fps}`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.vcodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.videoCodec'),
|
||||
value: download.selectedFormat.vcodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.acodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.audioCodec'),
|
||||
value: download.selectedFormat.acodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.format_note) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.formatNote'),
|
||||
value: download.selectedFormat.format_note
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.protocol) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.protocol'),
|
||||
value: download.selectedFormat.protocol.toUpperCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSubscriptionDownload) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.subscription'),
|
||||
value: subscriptionLabel
|
||||
})
|
||||
}
|
||||
|
||||
const hasMetadataDetails = metadataDetails.length > 0
|
||||
|
||||
const isSelectedHistory = selectionEnabled && isSelected
|
||||
|
||||
return (
|
||||
<div className="group relative w-full max-w-full overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
||||
<div
|
||||
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
|
||||
isSelectedHistory ? 'bg-primary/10' : ''
|
||||
}`}
|
||||
>
|
||||
{isSelectedHistory && (
|
||||
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
|
||||
)}
|
||||
<div
|
||||
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
|
||||
selectionEnabled ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
{...(selectionEnabled
|
||||
? {
|
||||
onClick: () => onToggleSelect?.(download.id),
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggleSelect?.(download.id)
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': t('history.selectItem')
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
||||
<ImageWithPlaceholder
|
||||
src={thumbnailSrc}
|
||||
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
|
||||
{selectionEnabled && (
|
||||
<div
|
||||
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={Boolean(isSelected)}
|
||||
onCheckedChange={() => onToggleSelect?.(download.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={t('history.selectItem')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RemoteImage
|
||||
src={download.thumbnail}
|
||||
alt={download.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
fallbackIcon={<Play className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
|
||||
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
|
||||
{download.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
|
||||
{download.type}
|
||||
</Badge>
|
||||
{(statusIcon || statusText) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{statusIcon}
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{timestamp ? (
|
||||
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
|
||||
) : null}
|
||||
|
||||
<span className="truncate max-w-[120px] text-left">
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{/* Status */}
|
||||
{statusIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{download.uploader &&
|
||||
download.channel &&
|
||||
download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader
|
||||
? `${download.uploader}`
|
||||
: download.channel
|
||||
? `${download.channel}`
|
||||
: ''}
|
||||
</a>
|
||||
<div className="flex items-center shrink-0">{statusIcon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
<TooltipContent>
|
||||
<p>{statusText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
|
||||
{download.selectedFormat ? (
|
||||
)}
|
||||
{/* Timestamp */}
|
||||
{timestamp && (
|
||||
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
||||
)}
|
||||
{/* Quality */}
|
||||
{qualityLabel && (
|
||||
<>
|
||||
{download.selectedFormat.height ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.height}p
|
||||
{download.selectedFormat.fps === 60 ? '60' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.ext ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.ext.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
|
||||
<span className="text-[10px] opacity-75">
|
||||
{formatFileSize(
|
||||
download.selectedFormat.filesize ||
|
||||
download.selectedFormat.filesize_approx
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{(statusIcon || timestamp) && (
|
||||
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||
)}
|
||||
<span className="shrink-0">{qualityLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{/* File size */}
|
||||
{inlineFileSize && (
|
||||
<>
|
||||
{download.quality ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.quality}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.format ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.format.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.codec ? (
|
||||
<span className="text-[10px] opacity-75">{download.codec}</span>
|
||||
) : null}
|
||||
{(statusIcon || timestamp || qualityLabel) && (
|
||||
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||
)}
|
||||
<span className="shrink-0">{inlineFileSize}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={actionsContainerClass}>
|
||||
<div className={`${actionsContainerClass} pointer-events-auto`}>
|
||||
{/* Info button - show details in sheet */}
|
||||
{hasMetadataDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSheetOpen(true)
|
||||
}}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('download.showDetails')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isHistory ? (
|
||||
<>
|
||||
{download.status === 'completed' && (
|
||||
@@ -294,9 +780,12 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!download.title || !download.downloadPath || !download.format}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -310,8 +799,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -327,8 +819,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleRemoveHistory}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveHistory()
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -347,8 +842,12 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -362,8 +861,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -380,8 +882,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCancel}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCancel()
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -393,13 +898,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
{/* Progress */}
|
||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1.5 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
|
||||
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
|
||||
<span className="font-medium shrink-0">
|
||||
{download.progress.percent.toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
|
||||
{download.progress.downloaded && download.progress.total && (
|
||||
<span className="truncate max-w-[100px]">
|
||||
{download.progress.downloaded} / {download.progress.total}
|
||||
@@ -424,6 +929,32 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Details Sheet */}
|
||||
{hasMetadataDetails && (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col p-0">
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle className="line-clamp-2">{download.title}</SheetTitle>
|
||||
<SheetDescription>{t('download.videoInfo')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{metadataDetails.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-sm text-foreground break-words">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
171
src/renderer/src/components/download/PlaylistDownloadGroup.tsx
Normal file
171
src/renderer/src/components/download/PlaylistDownloadGroup.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { Button } from '../ui/button'
|
||||
import { Progress } from '../ui/progress'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
|
||||
interface PlaylistDownloadGroupProps {
|
||||
groupId: string
|
||||
title: string
|
||||
records: DownloadRecord[]
|
||||
totalCount: number
|
||||
selectedIds?: Set<string>
|
||||
onToggleSelect?: (id: string) => void
|
||||
onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'playlist_expanded_'
|
||||
|
||||
const getStorageKey = (groupId: string): string => {
|
||||
return `${STORAGE_KEY_PREFIX}${groupId}`
|
||||
}
|
||||
|
||||
const loadExpandedState = (groupId: string): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(groupId))
|
||||
return stored === 'true'
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist expanded state:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const saveExpandedState = (groupId: string, isExpanded: boolean): void => {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(groupId), String(isExpanded))
|
||||
} catch (error) {
|
||||
console.error('Failed to save playlist expanded state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function PlaylistDownloadGroup({
|
||||
groupId,
|
||||
title,
|
||||
records,
|
||||
totalCount,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onDeletePlaylist
|
||||
}: PlaylistDownloadGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId))
|
||||
|
||||
useEffect(() => {
|
||||
saveExpandedState(groupId, isExpanded)
|
||||
}, [groupId, isExpanded])
|
||||
|
||||
const completedCount = records.filter((record) => record.status === 'completed').length
|
||||
const errorCount = records.filter((record) => record.status === 'error').length
|
||||
const activeCount = records.filter((record) =>
|
||||
['downloading', 'processing', 'pending'].includes(record.status)
|
||||
).length
|
||||
|
||||
const displayTitle = title || t('playlist.untitled')
|
||||
const historyRecords = records.filter((record) => record.entryType === 'history')
|
||||
const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist)
|
||||
const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand')
|
||||
const totalProgress = records.reduce((acc, record) => {
|
||||
if (record.status === 'completed') {
|
||||
return acc + 1
|
||||
}
|
||||
if (record.progress?.percent && record.progress.percent > 0) {
|
||||
return acc + Math.min(record.progress.percent, 100) / 100
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate text-sm font-medium text-foreground">{displayTitle}</p>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>{t('playlist.groupActive', { count: activeCount })}</span>
|
||||
</>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-destructive">
|
||||
{t('playlist.groupErrors', { count: errorCount })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canDeletePlaylist && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeletePlaylist?.(
|
||||
groupId,
|
||||
displayTitle,
|
||||
historyRecords.map((record) => record.id)
|
||||
)
|
||||
}}
|
||||
aria-label={t('history.deletePlaylist')}
|
||||
title={t('history.deletePlaylist')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExpanded && totalCount > 0 && (
|
||||
<Progress value={aggregatePercent} className="h-0.5 w-full" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr'
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="space-y-3 pt-1">
|
||||
{records.map((record) => (
|
||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||
<DownloadItem
|
||||
download={record}
|
||||
isSelected={selectedIds?.has(record.id) ?? false}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,147 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@renderer/components/ui/dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { History as HistoryIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import {
|
||||
downloadStatsAtom,
|
||||
downloadsArrayAtom,
|
||||
removeHistoryRecordsAtom,
|
||||
removeHistoryRecordsByPlaylistAtom
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
import { DownloadDialog } from './DownloadDialog'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||
type ConfirmAction =
|
||||
| { type: 'delete-selected'; ids: string[] }
|
||||
| { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }
|
||||
|
||||
export function UnifiedDownloadHistory() {
|
||||
const normalizeSavedFileName = (fileName?: string): string | undefined => {
|
||||
if (!fileName) {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = fileName.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
|
||||
}
|
||||
|
||||
const generateFilePathCandidates = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
const safeTitle = title.trim() || 'Unknown'
|
||||
|
||||
const savedNameCandidates: string[] = []
|
||||
const trimmedSavedFileName = savedFileName?.trim()
|
||||
if (trimmedSavedFileName) {
|
||||
const normalized = normalizeSavedFileName(trimmedSavedFileName)
|
||||
if (normalized) {
|
||||
savedNameCandidates.push(normalized)
|
||||
}
|
||||
if (!normalized || normalized !== trimmedSavedFileName) {
|
||||
savedNameCandidates.push(trimmedSavedFileName)
|
||||
}
|
||||
}
|
||||
|
||||
const candidateFileNames =
|
||||
savedNameCandidates.length > 0
|
||||
? savedNameCandidates
|
||||
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
|
||||
return Array.from(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
}
|
||||
|
||||
const tryFileOperation = async (
|
||||
paths: string[],
|
||||
operation: (filePath: string) => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
for (const filePath of paths) {
|
||||
const success = await operation(filePath)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||
const normalized = normalizeSavedFileName(fileName)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
if (!normalized.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
const ext = normalized.split('.').pop()
|
||||
return ext?.toLowerCase()
|
||||
}
|
||||
|
||||
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
if (savedExt) {
|
||||
return savedExt
|
||||
}
|
||||
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||
if (selectedExt) {
|
||||
return selectedExt
|
||||
}
|
||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||
}
|
||||
|
||||
interface UnifiedDownloadHistoryProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
}
|
||||
|
||||
export function UnifiedDownloadHistory({
|
||||
onOpenSupportedSites,
|
||||
onOpenSettings
|
||||
}: UnifiedDownloadHistoryProps) {
|
||||
const { t } = useTranslation()
|
||||
const allRecords = useAtomValue(downloadsArrayAtom)
|
||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||
const clearCompleted = useSetAtom(clearCompletedAtom)
|
||||
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
|
||||
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
|
||||
const [confirmBusy, setConfirmBusy] = useState(false)
|
||||
const [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false)
|
||||
const alsoDeleteFilesId = useId()
|
||||
|
||||
useHistorySync()
|
||||
|
||||
const historyRecords = useMemo(
|
||||
() => allRecords.filter((record) => record.entryType === 'history'),
|
||||
[allRecords]
|
||||
)
|
||||
const selectedCount = selectedIds.size
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return allRecords.filter((record) => {
|
||||
switch (statusFilter) {
|
||||
@@ -46,69 +169,390 @@ export function UnifiedDownloadHistory() {
|
||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||
]
|
||||
|
||||
const hasCompletedActive = allRecords.some(
|
||||
(item) => item.entryType === 'active' && item.status === 'completed'
|
||||
const selectableIds = useMemo(
|
||||
() =>
|
||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||
[filteredRecords]
|
||||
)
|
||||
const handleClearCompleted = () => {
|
||||
clearCompleted()
|
||||
const selectableCount = selectableIds.length
|
||||
const selectionSummary =
|
||||
selectableCount === 0
|
||||
? t('history.selectedCount', { count: selectedCount })
|
||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
const historyIdSet = new Set(historyRecords.map((record) => record.id))
|
||||
setSelectedIds((prev) => {
|
||||
let changed = false
|
||||
const next = new Set<string>()
|
||||
for (const id of prev) {
|
||||
if (historyIdSet.has(id)) {
|
||||
next.add(id)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [historyRecords, selectedIds.size])
|
||||
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleRequestDeleteSelected = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) })
|
||||
}
|
||||
|
||||
const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'delete-playlist', playlistId, title, ids })
|
||||
}
|
||||
|
||||
const pruneSelectedIds = (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
let changed = false
|
||||
ids.forEach((id) => {
|
||||
if (next.delete(id)) {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
const confirmContent = useMemo(() => {
|
||||
if (!confirmAction) {
|
||||
return null
|
||||
}
|
||||
switch (confirmAction.type) {
|
||||
case 'delete-selected': {
|
||||
return {
|
||||
title: t('history.confirmDeleteSelectedTitle'),
|
||||
description: t('history.confirmDeleteSelectedDescription', {
|
||||
count: confirmAction.ids.length
|
||||
}),
|
||||
actionLabel: t('history.removeAction')
|
||||
}
|
||||
}
|
||||
case 'delete-playlist': {
|
||||
return {
|
||||
title: t('history.confirmDeletePlaylistTitle'),
|
||||
description: t('history.confirmDeletePlaylistDescription', {
|
||||
count: confirmAction.ids.length,
|
||||
title: confirmAction.title
|
||||
}),
|
||||
actionLabel: t('history.removeAction')
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [confirmAction, t])
|
||||
|
||||
const deleteHistoryFiles = async (records: DownloadRecord[]) => {
|
||||
const failedIds: string[] = []
|
||||
for (const record of records) {
|
||||
if (!record.title) {
|
||||
continue
|
||||
}
|
||||
const downloadPath = record.downloadPath || settings.downloadPath
|
||||
if (!downloadPath) {
|
||||
continue
|
||||
}
|
||||
const formatForPath = resolveDownloadExtension(record)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
record.title,
|
||||
formatForPath,
|
||||
record.savedFileName
|
||||
)
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
failedIds.push(record.id)
|
||||
}
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
console.warn('Failed to delete some playlist files:', failedIds)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!confirmAction) {
|
||||
return
|
||||
}
|
||||
setConfirmBusy(true)
|
||||
try {
|
||||
if (confirmAction.type === 'delete-selected') {
|
||||
await ipcServices.history.removeHistoryItems(confirmAction.ids)
|
||||
removeHistoryRecords(confirmAction.ids)
|
||||
if (alsoDeleteFiles) {
|
||||
const idSet = new Set(confirmAction.ids)
|
||||
const recordsToDelete = historyRecords.filter((record) => idSet.has(record.id))
|
||||
await deleteHistoryFiles(recordsToDelete)
|
||||
}
|
||||
pruneSelectedIds(confirmAction.ids)
|
||||
toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length }))
|
||||
}
|
||||
if (confirmAction.type === 'delete-playlist') {
|
||||
const idSet = new Set(confirmAction.ids)
|
||||
const playlistRecords = historyRecords.filter((record) => idSet.has(record.id))
|
||||
await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId)
|
||||
removeHistoryRecordsByPlaylist(confirmAction.playlistId)
|
||||
await deleteHistoryFiles(playlistRecords)
|
||||
pruneSelectedIds(confirmAction.ids)
|
||||
toast.success(
|
||||
t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length })
|
||||
)
|
||||
}
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
} catch (error) {
|
||||
if (confirmAction.type === 'delete-selected') {
|
||||
console.error('Failed to remove selected history items:', error)
|
||||
toast.error(t('notifications.itemsRemoveFailed'))
|
||||
}
|
||||
if (confirmAction.type === 'delete-playlist') {
|
||||
console.error('Failed to remove playlist history:', error)
|
||||
toast.error(t('notifications.playlistHistoryRemoveFailed'))
|
||||
}
|
||||
} finally {
|
||||
setConfirmBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedView = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ id: string; title: string; totalCount: number; records: DownloadRecord[] }
|
||||
>()
|
||||
const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =
|
||||
[]
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
if (record.playlistId) {
|
||||
let group = groups.get(record.playlistId)
|
||||
if (!group) {
|
||||
group = {
|
||||
id: record.playlistId,
|
||||
title: record.playlistTitle || record.title,
|
||||
totalCount: record.playlistSize || 0,
|
||||
records: []
|
||||
}
|
||||
groups.set(record.playlistId, group)
|
||||
order.push({ type: 'group', id: record.playlistId })
|
||||
}
|
||||
group.records.push(record)
|
||||
if (!group.title && record.playlistTitle) {
|
||||
group.title = record.playlistTitle
|
||||
}
|
||||
if (!group.totalCount && record.playlistSize) {
|
||||
group.totalCount = record.playlistSize
|
||||
}
|
||||
} else {
|
||||
order.push({ type: 'single', record })
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
group.records.sort((a, b) => {
|
||||
const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex
|
||||
}
|
||||
return b.createdAt - a.createdAt
|
||||
})
|
||||
if (!group.totalCount) {
|
||||
group.totalCount = group.records.length
|
||||
}
|
||||
}
|
||||
|
||||
return { order, groups }
|
||||
}, [filteredRecords])
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('download.downloadQueue')}</CardTitle>
|
||||
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
|
||||
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
<span>{filter.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
|
||||
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
|
||||
)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{hasCompletedActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 border border-border/60 px-3"
|
||||
onClick={handleClearCompleted}
|
||||
>
|
||||
{t('download.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadDialog
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
<span>{filter.label}</span>
|
||||
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 overflow-hidden w-full">
|
||||
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<HistoryIcon className="h-10 w-10 opacity-50" />
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
|
||||
{filteredRecords.map((record) => (
|
||||
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
|
||||
))}
|
||||
<div className="space-y-4 w-full">
|
||||
{groupedView.order.map((item) => {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={`${item.record.entryType}:${item.record.id}`}
|
||||
download={item.record}
|
||||
isSelected={selectedIds.has(item.record.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const group = groupedView.groups.get(item.id)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistDownloadGroup
|
||||
key={`group:${group.id}`}
|
||||
groupId={group.id}
|
||||
title={group.title}
|
||||
totalCount={group.totalCount}
|
||||
records={group.records}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onDeletePlaylist={handleRequestDeletePlaylist}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{selectedCount > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{selectionSummary}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3"
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
{t('history.clearSelection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3"
|
||||
onClick={handleRequestDeleteSelected}
|
||||
>
|
||||
{t('history.deleteSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={Boolean(confirmAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !confirmBusy) {
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmContent && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmContent.title}</DialogTitle>
|
||||
<DialogDescription>{confirmContent.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{confirmAction?.type === 'delete-selected' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={alsoDeleteFilesId}
|
||||
checked={alsoDeleteFiles}
|
||||
onCheckedChange={(checked) => setAlsoDeleteFiles(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={alsoDeleteFilesId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{t('history.alsoDeleteFiles')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
}}
|
||||
disabled={confirmBusy}
|
||||
>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmAction} disabled={confirmBusy}>
|
||||
{confirmContent.actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import type { PlaylistEntry, PlaylistInfo } from '@shared/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PlaylistPreviewCardProps {
|
||||
playlist: PlaylistInfo
|
||||
entries: PlaylistEntry[]
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const totalCount = playlist.entryCount
|
||||
const selectedCount = entries.length
|
||||
const firstIndex = entries[0]?.index ?? null
|
||||
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<CardTitle
|
||||
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
|
||||
title={playlist.title}
|
||||
>
|
||||
{playlist.title || t('playlist.untitled')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
|
||||
{firstIndex !== null && lastIndex !== null ? (
|
||||
<span className="truncate">
|
||||
{t('playlist.selectedRange', {
|
||||
start: firstIndex,
|
||||
end: lastIndex
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{t('playlist.noRangeSelected')}</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{t('playlist.showingCount', { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
{onClear && (
|
||||
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
|
||||
{t('playlist.clearPreview')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20">
|
||||
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
|
||||
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
|
||||
{entries.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('playlist.noEntriesInRange')}
|
||||
</li>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<li
|
||||
key={`${entry.index}-${entry.id}`}
|
||||
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
|
||||
#{entry.index}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
|
||||
title={entry.title}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ol>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@renderer/components/ui/dialog'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { settingsAtom } from '@renderer/store/settings'
|
||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const sanitizeCommaList = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||
|
||||
const sanitizeTemplateInput = (value: string) => value.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
|
||||
|
||||
const buildDefaultSubscriptionDirectory = (downloadPath: string) => {
|
||||
const trimmed = downloadPath.trim().replace(/[\\/]+$/, '')
|
||||
if (!trimmed) {
|
||||
return 'Subscriptions'
|
||||
}
|
||||
return `${trimmed}/Subscriptions`
|
||||
}
|
||||
|
||||
export interface SubscriptionFormData {
|
||||
url?: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface SubscriptionFormDialogProps {
|
||||
mode: 'add' | 'edit'
|
||||
subscription?: SubscriptionRule
|
||||
open: boolean
|
||||
onSave: (data: SubscriptionFormData) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SubscriptionFormDialog({
|
||||
mode,
|
||||
subscription,
|
||||
open,
|
||||
onSave,
|
||||
onClose
|
||||
}: SubscriptionFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||
|
||||
// Form state
|
||||
const [url, setUrl] = useState('')
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [onlyLatest, setOnlyLatest] = useState(false)
|
||||
const [downloadDirectory, setDownloadDirectory] = useState('')
|
||||
const [namingTemplate, setNamingTemplate] = useState('')
|
||||
|
||||
// Feed detection state
|
||||
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||
|
||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
const urlInputId = useId()
|
||||
const advancedOptionsId = useId()
|
||||
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||
|
||||
// Initialize form values based on mode
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setAdvancedOptionsOpen(false)
|
||||
|
||||
if (mode === 'edit' && subscription) {
|
||||
setUrl(subscription.feedUrl)
|
||||
setKeywords(subscription.keywords.join(', '))
|
||||
setTags(subscription.tags.join(', '))
|
||||
setOnlyLatest(subscription.onlyDownloadLatest)
|
||||
setDownloadDirectory(subscription.downloadDirectory || '')
|
||||
setNamingTemplate(subscription.namingTemplate || '')
|
||||
} else {
|
||||
// Add mode - use defaults from settings
|
||||
setUrl('')
|
||||
setKeywords('')
|
||||
setTags('')
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
setDownloadDirectory(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)
|
||||
}
|
||||
}, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])
|
||||
|
||||
// Sync download directory with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)
|
||||
setDownloadDirectory((prev) => {
|
||||
if (!prev || prev === prevDefaultPathRef.current) {
|
||||
return newPath
|
||||
}
|
||||
return prev
|
||||
})
|
||||
prevDefaultPathRef.current = newPath
|
||||
}
|
||||
}, [settings.downloadPath, mode])
|
||||
|
||||
// Sync onlyLatest with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
}
|
||||
}, [settings.subscriptionOnlyLatestDefault, mode])
|
||||
|
||||
// Feed detection logic
|
||||
useEffect(() => {
|
||||
if (!url.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// In edit mode, don't detect if URL hasn't changed
|
||||
if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
|
||||
detectTimeout.current = setTimeout(async () => {
|
||||
setDetectingFeed(true)
|
||||
try {
|
||||
await resolveFeed(url.trim())
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
} finally {
|
||||
setDetectingFeed(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
}
|
||||
}, [url, resolveFeed, mode, subscription])
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setDownloadDirectory(path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('subscriptions.notifications.directoryError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRSSHubDocs = async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate URL for add mode
|
||||
if (mode === 'add' && !url.trim()) {
|
||||
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
const formData: SubscriptionFormData = {
|
||||
keywords: sanitizeCommaList(keywords),
|
||||
tags: sanitizeCommaList(tags),
|
||||
onlyDownloadLatest: onlyLatest,
|
||||
downloadDirectory: downloadDirectory || undefined,
|
||||
namingTemplate: namingTemplate || undefined
|
||||
}
|
||||
|
||||
// Include URL if it's provided and different from current (for edit mode)
|
||||
if (url.trim()) {
|
||||
if (
|
||||
mode === 'add' ||
|
||||
(mode === 'edit' && subscription && url.trim() !== subscription.feedUrl)
|
||||
) {
|
||||
try {
|
||||
await resolveFeed(url.trim())
|
||||
formData.url = url.trim()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
toast.error(t('subscriptions.notifications.resolveError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(formData)
|
||||
}
|
||||
|
||||
const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'
|
||||
const descriptionKey =
|
||||
mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'
|
||||
const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' && subscription
|
||||
? t(titleKey, { name: subscription.title })
|
||||
: t(titleKey)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t(descriptionKey)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
|
||||
<Input
|
||||
id={urlInputId}
|
||||
value={url}
|
||||
placeholder={t('subscriptions.placeholders.url')}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
{detectingFeed && (
|
||||
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
|
||||
)}
|
||||
{mode === 'add' && !url.trim() && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground flex-1">
|
||||
{t('subscriptions.rssHub.hint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title={t('subscriptions.rssHub.openDocs')}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={downloadDirectory} readOnly />
|
||||
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
|
||||
{t('subscriptions.actions.selectDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.tags')}</Label>
|
||||
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
||||
<Input
|
||||
value={namingTemplate}
|
||||
onChange={(event) =>
|
||||
setNamingTemplate(sanitizeTemplateInput(event.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={advancedOptionsId}
|
||||
checked={advancedOptionsOpen}
|
||||
onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
|
||||
{t('advancedOptions.title')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{mode === 'add' && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,10 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
|
||||
className={cn('rounded-lg bg-muted/30 text-card-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,5 +22,4 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
||||
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup
|
||||
}
|
||||
@@ -1,101 +1,126 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
DialogTrigger
|
||||
}
|
||||
|
||||
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type * as React from 'react'
|
||||
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -9,6 +9,7 @@ interface ImageWithPlaceholderProps {
|
||||
placeholderClassName?: string
|
||||
fallbackIcon?: React.ReactNode
|
||||
onError?: () => void
|
||||
onLoad?: () => void
|
||||
}
|
||||
|
||||
export function ImageWithPlaceholder({
|
||||
@@ -17,7 +18,8 @@ export function ImageWithPlaceholder({
|
||||
className,
|
||||
placeholderClassName,
|
||||
fallbackIcon,
|
||||
onError
|
||||
onError,
|
||||
onLoad
|
||||
}: ImageWithPlaceholderProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -30,6 +32,7 @@ export function ImageWithPlaceholder({
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false)
|
||||
onLoad?.()
|
||||
}
|
||||
|
||||
// Show placeholder if no src, error occurred, or still loading
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'flex h-9 w-full rounded-md border px-3 py-1 text-base bg-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
123
src/renderer/src/components/ui/remote-image.tsx
Normal file
123
src/renderer/src/components/ui/remote-image.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ImageWithPlaceholder } from './image-with-placeholder'
|
||||
|
||||
interface RemoteImageProps {
|
||||
/**
|
||||
* Remote image URL or local file path
|
||||
* If it's a remote URL (http/https), it will be cached automatically
|
||||
* If it's a local path (vidbee://, file:// or data:), it will be used directly
|
||||
*/
|
||||
src?: string | null
|
||||
alt: string
|
||||
className?: string
|
||||
placeholderClassName?: string
|
||||
fallbackIcon?: React.ReactNode
|
||||
/**
|
||||
* Custom loading icon/indicator
|
||||
* @default Loader2 spinner
|
||||
*/
|
||||
loadingIcon?: React.ReactNode
|
||||
onError?: () => void
|
||||
/**
|
||||
* Callback when loading state changes
|
||||
*/
|
||||
onLoadingChange?: (loading: boolean) => void
|
||||
/**
|
||||
* Whether to use thumbnail cache for remote URLs
|
||||
* @default true
|
||||
*/
|
||||
useCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified remote image component with automatic caching support
|
||||
*
|
||||
* This component automatically handles:
|
||||
* - Remote URL caching via thumbnail cache service
|
||||
* - Local file paths (vidbee://, file://, data:)
|
||||
* - Loading states and error handling
|
||||
* - Placeholder display with loading indicator
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Remote URL with automatic caching
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
|
||||
*
|
||||
* // Local file path (no caching)
|
||||
* <RemoteImage src="vidbee://thumbnails/example.jpg" alt="Local" />
|
||||
*
|
||||
* // Without cache (direct load)
|
||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
|
||||
*
|
||||
* // Custom loading icon
|
||||
* <RemoteImage
|
||||
* src="https://example.com/image.jpg"
|
||||
* alt="Example"
|
||||
* loadingIcon={<CustomSpinner />}
|
||||
* />
|
||||
*
|
||||
* // Track loading state
|
||||
* <RemoteImage
|
||||
* src="https://example.com/image.jpg"
|
||||
* alt="Example"
|
||||
* onLoadingChange={(loading) => console.log('Loading:', loading)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function RemoteImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
placeholderClassName,
|
||||
fallbackIcon,
|
||||
loadingIcon,
|
||||
onError,
|
||||
onLoadingChange,
|
||||
useCache = true
|
||||
}: RemoteImageProps) {
|
||||
const shouldUseCache =
|
||||
useCache &&
|
||||
src &&
|
||||
!src.startsWith(APP_PROTOCOL_SCHEME) &&
|
||||
!src.startsWith('file://') &&
|
||||
!src.startsWith('data:') &&
|
||||
(src.startsWith('http://') || src.startsWith('https://'))
|
||||
|
||||
const cachedSrc = useCachedThumbnail(shouldUseCache ? src : undefined)
|
||||
|
||||
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
|
||||
|
||||
const [isImageLoading, setIsImageLoading] = useState(true)
|
||||
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
|
||||
const isLoading = isCacheLoading || isImageLoading
|
||||
|
||||
useEffect(() => {
|
||||
if (imageSrc) {
|
||||
setIsImageLoading(true)
|
||||
} else {
|
||||
setIsImageLoading(false)
|
||||
}
|
||||
}, [imageSrc])
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(isLoading)
|
||||
}, [isLoading, onLoadingChange])
|
||||
|
||||
const defaultLoadingIcon = <Loader2 className="h-6 w-6 animate-spin" />
|
||||
const displayLoadingIcon = loadingIcon ?? defaultLoadingIcon
|
||||
|
||||
return (
|
||||
<ImageWithPlaceholder
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
placeholderClassName={placeholderClassName}
|
||||
fallbackIcon={isLoading ? displayLoadingIcon : fallbackIcon}
|
||||
onError={onError}
|
||||
onLoad={() => setIsImageLoading(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
127
src/renderer/src/components/ui/sheet.tsx
Normal file
127
src/renderer/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
} from '@renderer/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { saveSettingAtom } from '@renderer/store/settings'
|
||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import '../../assets/title-bar.css'
|
||||
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
||||
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
||||
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
||||
@@ -17,12 +19,25 @@ import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||
import MingcuteRssLine from '~icons/mingcute/rss-line'
|
||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||
type NavigationTarget = Page | 'supported-sites'
|
||||
|
||||
interface NavigationItem {
|
||||
id: NavigationTarget
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
inactive: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface PageNavigationItem {
|
||||
id: Page
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
@@ -34,16 +49,14 @@ interface NavigationItem {
|
||||
interface SidebarProps {
|
||||
currentPage: Page
|
||||
onPageChange: (page: Page) => void
|
||||
onOpenSupportedSites: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文' }
|
||||
]
|
||||
const languageOptions = languageList
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
@@ -55,16 +68,25 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
label: t('menu.download')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
id: 'subscriptions',
|
||||
icon: {
|
||||
active: MingcuteRssFill,
|
||||
inactive: MingcuteRssLine
|
||||
},
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'supported-sites',
|
||||
icon: {
|
||||
active: MingcuteCheckCircleFill,
|
||||
inactive: MingcuteCheckCircleLine
|
||||
},
|
||||
label: t('menu.supportedSites')
|
||||
label: t('menu.supportedSites'),
|
||||
onClick: onOpenSupportedSites
|
||||
}
|
||||
]
|
||||
|
||||
const bottomNavigationItems: NavigationItem[] = [
|
||||
const bottomNavigationItems: PageNavigationItem[] = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: {
|
||||
@@ -83,11 +105,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
]
|
||||
|
||||
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
|
||||
const activeLanguageCode = normalizeLanguageCode(i18n.language)
|
||||
const currentLanguage =
|
||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
const handleLanguageChange = async (value: LanguageCode) => {
|
||||
if (activeLanguageCode === value) {
|
||||
return
|
||||
}
|
||||
@@ -98,26 +120,21 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
||||
const isActive = currentPage === item.id
|
||||
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||
const handleClick = item.onClick ?? (() => onPageChange(item.id as Page))
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClick}
|
||||
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
||||
{item.label}
|
||||
@@ -128,9 +145,9 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
{/* App Logo */}
|
||||
<div className="flex flex-col items-center gap-1 py-4 mb-1">
|
||||
<div className="flex flex-col items-center gap-1 py-3 mt-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center">
|
||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||
</div>
|
||||
@@ -150,7 +167,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-12 h-12">
|
||||
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
|
||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -167,9 +184,13 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => void handleLanguageChange(option.value)}
|
||||
className={isActive ? 'font-semibold' : undefined}
|
||||
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
|
||||
aria-current={isActive}
|
||||
>
|
||||
{option.label}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
|
||||
<span lang={option.hreflang}>{option.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
@@ -190,7 +211,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -7,11 +7,15 @@ import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
|
||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||
import '../../assets/title-bar.css'
|
||||
|
||||
export function TitleBar() {
|
||||
interface TitleBarProps {
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export function TitleBar({ platform }: TitleBarProps) {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听窗口最大化状态变化
|
||||
// Listen for window maximize state changes
|
||||
const handleMaximized = () => {
|
||||
setIsMaximized(true)
|
||||
}
|
||||
@@ -41,8 +45,17 @@ export function TitleBar() {
|
||||
ipcServices.window.close()
|
||||
}
|
||||
|
||||
const isMac = platform === 'darwin'
|
||||
const containerClass = `flex drag-region bg-background select-none ${
|
||||
isMac ? 'h-10 items-center px-4' : 'justify-end pt-4 px-5'
|
||||
}`
|
||||
|
||||
if (isMac) {
|
||||
return <div className={containerClass} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
|
||||
<div className={containerClass}>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-1 no-drag">
|
||||
<Button
|
||||
|
||||
@@ -4,15 +4,10 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@renderer/components/ui/accordion'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
interface AdvancedOptionsProps {
|
||||
startTime: string
|
||||
@@ -21,6 +16,7 @@ interface AdvancedOptionsProps {
|
||||
onStartTimeChange: (value: string) => void
|
||||
onEndTimeChange: (value: string) => void
|
||||
onDownloadSubsChange: (value: boolean) => void
|
||||
showAccordion?: boolean
|
||||
}
|
||||
|
||||
export function AdvancedOptions({
|
||||
@@ -29,71 +25,65 @@ export function AdvancedOptions({
|
||||
downloadSubs,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
onDownloadSubsChange
|
||||
onDownloadSubsChange,
|
||||
showAccordion = true
|
||||
}: AdvancedOptionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
const handleSelectLocation = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
await ipcServices.settings.set('downloadPath', path)
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error('Failed to select directory')
|
||||
}
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground ml-1">
|
||||
{t('advancedOptions.timeRange')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative group">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.startPlaceholder')}
|
||||
value={startTime}
|
||||
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||
className="h-9 text-center"
|
||||
title={t('advancedOptions.startHint')}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<div className="flex-1 relative group">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.endPlaceholder')}
|
||||
value={endTime}
|
||||
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||
className="h-9 text-center"
|
||||
title={t('advancedOptions.endHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-semibold">{t('advancedOptions.downloadSubs')}</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('advancedOptions.downloadSubsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!showAccordion) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('advancedOptions.timeRange')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.startPlaceholder')}
|
||||
value={startTime}
|
||||
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||
title={t('advancedOptions.startHint')}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.endPlaceholder')}
|
||||
value={endTime}
|
||||
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||
title={t('advancedOptions.endHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('advancedOptions.startHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('advancedOptions.downloadSubs')}</Label>
|
||||
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
||||
</div>
|
||||
|
||||
{/* Download Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('advancedOptions.downloadLocation')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={settings.downloadPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectLocation} variant="outline">
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced" className="border-b">
|
||||
<AccordionTrigger className="flex items-center gap-2 py-4 text-sm font-semibold hover:no-underline">
|
||||
<span className="flex-1 text-left">{t('advancedOptions.title')}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-6 pt-2">{content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
@@ -8,19 +7,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
onExtract: (type: 'extract') => void
|
||||
export interface AudioExtractorState {
|
||||
extractFormat: string
|
||||
extractQuality: string
|
||||
}
|
||||
|
||||
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
state: AudioExtractorState
|
||||
onStateChange: (state: Partial<AudioExtractorState>) => void
|
||||
}
|
||||
|
||||
export function AudioExtractor({
|
||||
videoInfo: _videoInfo,
|
||||
state,
|
||||
onStateChange
|
||||
}: AudioExtractorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [extractFormat, setExtractFormat] = useState('mp3')
|
||||
const [extractQuality, setExtractQuality] = useState('5')
|
||||
const { extractFormat, extractQuality } = state
|
||||
|
||||
const audioFormats = [
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
@@ -41,46 +48,50 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('audioExtract.title')}</CardTitle>
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectFormat')}</Label>
|
||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
||||
<Select
|
||||
value={extractFormat}
|
||||
onValueChange={(value) => onStateChange({ extractFormat: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||
<Select
|
||||
value={extractQuality}
|
||||
onValueChange={(value) => onStateChange({ extractQuality: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onExtract('extract')} className="w-full">
|
||||
{t('audioExtract.extract')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
@@ -73,9 +72,22 @@ export function FormatSelector({
|
||||
|
||||
useEffect(() => {
|
||||
// Filter and sort formats
|
||||
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
|
||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||
const videos = formats.filter(
|
||||
(f) =>
|
||||
f.video_ext !== 'none' &&
|
||||
f.vcodec &&
|
||||
f.vcodec !== 'none' &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
const audios = formats.filter(
|
||||
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
|
||||
(f) =>
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext) &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
|
||||
// Apply showMoreFormats filter
|
||||
@@ -87,6 +99,48 @@ export function FormatSelector({
|
||||
? audios
|
||||
: audios.filter((f) => f.ext !== 'webm')
|
||||
|
||||
// Sort formats by quality (best first)
|
||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by height (higher is better)
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
// If same height, sort by fps (higher is better)
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by bitrate/quality if available
|
||||
const aQuality = a.tbr ?? a.quality ?? 0
|
||||
const bQuality = b.tbr ?? b.quality ?? 0
|
||||
if (aQuality !== bQuality) {
|
||||
return bQuality - aQuality
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(filteredVideos)
|
||||
setAudioFormats(filteredAudios)
|
||||
|
||||
@@ -121,25 +175,50 @@ export function FormatSelector({
|
||||
}
|
||||
|
||||
const formatVideoLabel = (format: VideoFormat) => {
|
||||
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
|
||||
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
|
||||
const parts: string[] = []
|
||||
// Resolution
|
||||
if (format.height) {
|
||||
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
|
||||
}
|
||||
// Format extension
|
||||
parts.push(format.ext.toUpperCase())
|
||||
// Codec (if showMoreFormats is enabled)
|
||||
if (settings.showMoreFormats && format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0])
|
||||
}
|
||||
// Audio indicator
|
||||
if (format.acodec !== 'none') {
|
||||
parts.push('🔊')
|
||||
}
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
|
||||
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioLabel = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Quality
|
||||
const quality = format.format_note || t('download.unknownQuality')
|
||||
parts.push(quality)
|
||||
// Format extension
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
return `${quality} | ${ext} | ${size}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectVideoFormat')}</Label>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||
<Select
|
||||
value={selectedVideo}
|
||||
onValueChange={(value) => {
|
||||
@@ -147,25 +226,25 @@ export function FormatSelector({
|
||||
onVideoFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{videoFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatVideoLabel(format)}
|
||||
<span className="text-sm">{formatVideoLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectAudioFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -173,18 +252,20 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
<SelectItem value="none" className="cursor-pointer py-2.5">
|
||||
<span className="text-sm">{t('download.noAudio')}</span>
|
||||
</SelectItem>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -196,8 +277,8 @@ export function FormatSelector({
|
||||
|
||||
// Audio only
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -205,17 +286,17 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { Clock, Eye, Play } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { addDownloadAtom } from '../../store/downloads'
|
||||
import { clearVideoInfoAtom } from '../../store/video'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
import { AudioExtractor } from './AudioExtractor'
|
||||
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
|
||||
import { FormatSelector } from './FormatSelector'
|
||||
|
||||
export interface VideoInfoCardState {
|
||||
title: string
|
||||
activeTab: 'video' | 'audio'
|
||||
selectedVideoFormat: string
|
||||
selectedAudioForVideo: string
|
||||
selectedAudioFormat: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
downloadSubs: boolean
|
||||
customDownloadPath: string
|
||||
audioExtractor: AudioExtractorState
|
||||
}
|
||||
|
||||
interface VideoInfoCardProps {
|
||||
videoInfo: VideoInfo
|
||||
state: VideoInfoCardState
|
||||
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||
onTabChange: (tab: 'video' | 'audio') => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
@@ -46,123 +47,62 @@ function formatViews(views?: number): string {
|
||||
return views.toString()
|
||||
}
|
||||
|
||||
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
export function VideoInfoCard({
|
||||
videoInfo,
|
||||
state,
|
||||
onStateChange,
|
||||
onTabChange
|
||||
}: VideoInfoCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const titleId = useId()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
|
||||
const [title, setTitle] = useState(videoInfo.title)
|
||||
const [selectedVideoFormat, setSelectedVideoFormat] = useState('')
|
||||
const [selectedAudioForVideo, setSelectedAudioForVideo] = useState('')
|
||||
const [selectedAudioFormat, setSelectedAudioFormat] = useState('')
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [downloadSubs, setDownloadSubs] = useState(false)
|
||||
|
||||
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
|
||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: videoInfo.webpage_url || '',
|
||||
title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
type: type === 'extract' ? 'audio' : type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
const options = {
|
||||
url: videoInfo.webpage_url || '',
|
||||
type,
|
||||
format: type === 'video' ? selectedVideoFormat : selectedAudioFormat,
|
||||
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
|
||||
startTime: startTime || undefined,
|
||||
endTime: endTime || undefined,
|
||||
downloadSubs
|
||||
}
|
||||
|
||||
addDownload(downloadItem)
|
||||
|
||||
try {
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
// Update the download info in the main process queue
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
toast.success(t('notifications.downloadStarted'))
|
||||
clearVideoInfo()
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error)
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
}
|
||||
const { title, activeTab } = state
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('download.back')}
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div>
|
||||
<Card className="overflow-hidden border shadow-sm">
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full md:w-80 rounded-lg aspect-video"
|
||||
fallbackIcon={<Play className="h-12 w-12" />}
|
||||
/>
|
||||
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2 items-center">
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{formatViews(videoInfo.view_count)}
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
|
||||
</CardDescription>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
|
||||
>
|
||||
{videoInfo.extractor_key || t('download.videoInfo')}
|
||||
</Badge>
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={titleId}>{t('download.title')}</Label>
|
||||
<Input
|
||||
id={titleId}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="font-medium"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
|
||||
{videoInfo.uploader && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,58 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
|
||||
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
|
||||
<CardContent className="p-3 pt-3">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
onTabChange(v as 'video' | 'audio')
|
||||
onStateChange({ activeTab: v as 'video' | 'audio' })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
|
||||
<TabsTrigger value="video" className="text-xs">
|
||||
{t('download.video')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="video" className="space-y-4 mt-4">
|
||||
<TabsContent value="video" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="video"
|
||||
onVideoFormatChange={setSelectedVideoFormat}
|
||||
onAudioFormatChange={setSelectedAudioForVideo}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
|
||||
/>
|
||||
|
||||
<AdvancedOptions
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
downloadSubs={downloadSubs}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadVideo')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audio" className="space-y-4 mt-4">
|
||||
<TabsContent value="audio" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
onAudioFormatChange={setSelectedAudioFormat}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
|
||||
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
|
||||
|
||||
<AdvancedOptions
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
downloadSubs={downloadSubs}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
<AudioExtractor
|
||||
videoInfo={videoInfo}
|
||||
state={state.audioExtractor}
|
||||
onStateChange={(updates) =>
|
||||
onStateChange({
|
||||
audioExtractor: { ...state.audioExtractor, ...updates }
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,98 +1,26 @@
|
||||
export interface PopularSite {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
url: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
export const popularSites: PopularSite[] = [
|
||||
{
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
description: 'Long-form and livestream video from creators worldwide.'
|
||||
},
|
||||
{
|
||||
id: 'youtubemusic',
|
||||
label: 'YouTube Music',
|
||||
description: 'Official music videos, albums, and live performances.'
|
||||
},
|
||||
{
|
||||
id: 'tiktok',
|
||||
label: 'TikTok',
|
||||
description: 'Short-form mobile videos, effects, and live streams.'
|
||||
},
|
||||
{
|
||||
id: 'facebook',
|
||||
label: 'Facebook',
|
||||
description: 'Feed, Watch, and Reels videos from public pages.'
|
||||
},
|
||||
{
|
||||
id: 'instagram',
|
||||
label: 'Instagram',
|
||||
description: 'Feed, Stories, Reels, and Highlights content.'
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
label: 'X (Twitter)',
|
||||
description: 'Timeline posts, Spaces recordings, and broadcasts.'
|
||||
},
|
||||
{
|
||||
id: 'soundcloud',
|
||||
label: 'SoundCloud',
|
||||
description: 'Music tracks, playlists, and DJ sets.'
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
label: 'Reddit',
|
||||
description: 'Embedded clips and hosted videos from communities.'
|
||||
},
|
||||
{
|
||||
id: 'vimeo',
|
||||
label: 'Vimeo',
|
||||
description: 'High-quality creator and business video hosting.'
|
||||
},
|
||||
{
|
||||
id: 'dailymotion',
|
||||
label: 'Dailymotion',
|
||||
description: 'Global news, sports, and entertainment clips.'
|
||||
},
|
||||
{
|
||||
id: 'twitch',
|
||||
label: 'Twitch',
|
||||
description: 'Gaming, music, and IRL live streams and VODs.'
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
description: 'Professional talks, webinars, and learning videos.'
|
||||
},
|
||||
{
|
||||
id: 'pinterest',
|
||||
label: 'Pinterest',
|
||||
description: 'Idea pins, how-to reels, and lifestyle inspiration videos.'
|
||||
},
|
||||
{
|
||||
id: 'tumblr',
|
||||
label: 'Tumblr',
|
||||
description: 'Creative short-form media and fan edits.'
|
||||
},
|
||||
{
|
||||
id: 'mixcloud',
|
||||
label: 'Mixcloud',
|
||||
description: 'DJ mixes, radio shows, and long-form audio.'
|
||||
},
|
||||
{
|
||||
id: 'niconico',
|
||||
label: 'Niconico',
|
||||
description: 'Japanese animation, music, and live broadcast archive.'
|
||||
},
|
||||
{
|
||||
id: 'kick',
|
||||
label: 'Kick',
|
||||
description: 'Creator live streams and replays on the Kick platform.'
|
||||
},
|
||||
{
|
||||
id: 'bandcamp',
|
||||
label: 'Bandcamp',
|
||||
description: 'Independent artist albums and community releases.'
|
||||
}
|
||||
{ id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },
|
||||
{ id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },
|
||||
{ id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },
|
||||
{ id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },
|
||||
{ id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },
|
||||
{ id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },
|
||||
{ id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },
|
||||
{ id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },
|
||||
{ id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },
|
||||
{ id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },
|
||||
{ id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },
|
||||
{ id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },
|
||||
{ id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },
|
||||
{ id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },
|
||||
{ id: 'kick', url: 'https://kick.com', domain: 'kick.com' },
|
||||
{ id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
||||
@@ -14,7 +14,11 @@ export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
||||
return
|
||||
}
|
||||
|
||||
if (url.startsWith('file://') || url.startsWith('data:')) {
|
||||
if (
|
||||
url.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||
url.startsWith('file://') ||
|
||||
url.startsWith('data:')
|
||||
) {
|
||||
setCachedUrl(url)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { defaultLanguageCode, supportedLanguageCodes } from '@shared/languages'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
type TranslationDictionary = typeof en
|
||||
|
||||
const localeModules = import.meta.glob<{ default: TranslationDictionary }>('./locales/*.json', {
|
||||
eager: true
|
||||
})
|
||||
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(localeModules).map(([path, module]) => {
|
||||
const code = path.replace('./locales/', '').replace('.json', '')
|
||||
return [code, module.default]
|
||||
})
|
||||
) as Record<string, TranslationDictionary>
|
||||
|
||||
const resources = Object.fromEntries(
|
||||
supportedLanguageCodes.map((code) => [
|
||||
code,
|
||||
{
|
||||
translation: translations[code] ?? en
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh }
|
||||
},
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources,
|
||||
lng: defaultLanguageCode,
|
||||
fallbackLng: defaultLanguageCode,
|
||||
supportedLngs: supportedLanguageCodes,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
|
||||
586
src/renderer/src/locales/ar.json
Normal file
586
src/renderer/src/locales/ar.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "التحقق من التحديثات",
|
||||
"download": "تحميل",
|
||||
"email": "البريد الإلكتروني",
|
||||
"feedback": "ملاحظات",
|
||||
"goToDownload": "الانتقال إلى صفحة التحميل",
|
||||
"openRepo": "فتح مستودع GitHub",
|
||||
"view": "عرض",
|
||||
"visit": "زيارة"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "تنزيل وتثبيت الإصدارات الجديدة تلقائياً في الخلفية.",
|
||||
"autoUpdateTitle": "التحديثات التلقائية",
|
||||
"betaProgramDescription": "تلقي البناءات المبكرة والميزات القادمة قبل أي شخص آخر.",
|
||||
"betaProgramTitle": "قناة المعاينة",
|
||||
"description": "VidBee هو برنامج تنزيل مجاني ومفتوح المصدر مبني باستخدام Electron ويعمل بواسطة yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "متابعة @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "ابق على اطلاع بآخر أخبار وتحديثات VidBee.",
|
||||
"followAuthorSupport": "تابع المطور على X (Twitter) للحصول على آخر التحديثات والأخبار حول VidBee.",
|
||||
"followAuthorTitle": "متابعة المطور",
|
||||
"here": "هنا",
|
||||
"homepage": "الصفحة الرئيسية",
|
||||
"notifications": {
|
||||
"checkingUpdates": "البحث عن التحديثات...",
|
||||
"downloadError": "فشل في تنزيل التحديث",
|
||||
"downloadStarted": "بدأ التحميل...",
|
||||
"downloadUpdate": "تنزيل وتثبيت التحديث {{version}}؟",
|
||||
"manualDownloadAction": "تنزيل الآن",
|
||||
"noUpdatesAvailable": "أنت تستخدم أحدث إصدار",
|
||||
"restartToUpdate": "إعادة التشغيل الآن لتثبيت التحديث؟",
|
||||
"restartNowAction": "إعادة التشغيل الآن",
|
||||
"updateAvailable": "تحديث متاح: {{version}}",
|
||||
"updateAvailableMessage": "إصدار جديد {{version}} متاح. يرجى تنزيله من الموقع الرسمي.",
|
||||
"updateDownloaded": "تم تنزيل التحديث، أعد التشغيل للتثبيت",
|
||||
"updateDownloadedVersion": "تم تنزيل التحديث {{version}}، أعد التشغيل للتثبيت",
|
||||
"updateError": "فشل في التحقق من التحديثات: {{error}}",
|
||||
"unknownErrorFallback": "خطأ غير معروف"
|
||||
},
|
||||
"preferencesDescription": "ضبط إعدادات التحديث دون مغادرة هذه الصفحة.",
|
||||
"preferencesTitle": "التبديلات السريعة",
|
||||
"resources": {
|
||||
"changelog": "ملاحظات الإصدار",
|
||||
"changelogDescription": "اطلع على ما تغير في كل إصدار.",
|
||||
"contact": "دعم البريد الإلكتروني",
|
||||
"contactDescription": "تواصل مباشرة للحصول على المساعدة أو التعاون.",
|
||||
"documentation": "مركز المساعدة",
|
||||
"documentationDescription": "أدلة، أسئلة شائعة، وسير عمل شائعة.",
|
||||
"feedback": "ملاحظات ومشاكل",
|
||||
"feedbackDescription": "شارك الأفكار أو أبلغ عن المشاكل على GitHub.",
|
||||
"license": "الترخيص",
|
||||
"licenseDescription": "راجع شروط ترخيص المصدر المفتوح.",
|
||||
"website": "الموقع الرسمي",
|
||||
"websiteDescription": "أبرز المنتجات، خارطة الطريق، وأخبار المجتمع."
|
||||
},
|
||||
"resourcesDescription": "روابط مفيدة لمعرفة المزيد عن VidBee والبقاء على اتصال.",
|
||||
"resourcesTitle": "الموارد",
|
||||
"shareActions": {
|
||||
"copy": "نسخ الرابط",
|
||||
"facebook": "مشاركة على Facebook",
|
||||
"twitter": "مشاركة على X (Twitter)"
|
||||
},
|
||||
"shareDescription": "شارك VidBee مع مجتمعك بنقرة واحدة.",
|
||||
"shareSupport": "أوصِ VidBee لأصدقائك لدعم نمونا وتحديثاتنا.",
|
||||
"shareTitle": "انشر الكلمة",
|
||||
"sourceCode": "الكود المصدري متاح",
|
||||
"title": "حول",
|
||||
"version": "الإصدار",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "الأحدث: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "إصدار جديد متاح",
|
||||
"uptodate": "أنت محدث",
|
||||
"error": "تعذر جلب أحدث إصدار"
|
||||
},
|
||||
"downloadingUpdate": "تنزيل التحديث"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "إغلاق التطبيق عند انتهاء التحميل",
|
||||
"currentLocation": "موقع التحميل الحالي - ",
|
||||
"downloadLocation": "موقع التحميل",
|
||||
"downloadSubs": "تحميل الترجمات إن كانت متاحة",
|
||||
"end": "النهاية",
|
||||
"endHint": "إذا تُرك فارغاً، سيتم التحميل حتى النهاية",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "اختر موقع التحميل",
|
||||
"start": "البداية",
|
||||
"startHint": "إذا تُرك فارغاً، سيبدأ من البداية",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "الترجمات",
|
||||
"timeRange": "تحميل نطاق زمني محدد",
|
||||
"title": "خيارات متقدمة"
|
||||
},
|
||||
"app": {
|
||||
"description": "تحميل الفيديوهات والصوتيات من مئات المواقع",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "سيء",
|
||||
"best": "أفضل",
|
||||
"extract": "استخراج",
|
||||
"good": "جيد",
|
||||
"normal": "عادي",
|
||||
"selectFormat": "اختر التنسيق",
|
||||
"selectQuality": "اختر الجودة",
|
||||
"title": "استخراج الصوت",
|
||||
"worst": "أسوأ"
|
||||
},
|
||||
"download": {
|
||||
"active": "نشط",
|
||||
"all": "الكل",
|
||||
"audio": "صوت",
|
||||
"back": "رجوع",
|
||||
"cancel": "إلغاء",
|
||||
"cancelled": "ملغي",
|
||||
"clearCompleted": "مسح المكتملة",
|
||||
"clearDownloads": "مسح التحميلات",
|
||||
"completed": "مكتمل",
|
||||
"downloadAudio": "تحميل الصوت",
|
||||
"downloadBtn": "تحميل",
|
||||
"downloadPending": "قيد الانتظار",
|
||||
"downloadQueue": "قائمة انتظار التحميل",
|
||||
"downloadVideo": "تحميل الفيديو",
|
||||
"downloading": "جاري التحميل...",
|
||||
"enterUrl": "أدخل رابط الفيديو",
|
||||
"enterUrlDescription": "الصق أو اكتب رابط فيديو. ",
|
||||
"error": "خطأ",
|
||||
"fetch": "جلب",
|
||||
"fetchingVideoInfo": "جلب معلومات الفيديو...",
|
||||
"history": "السجل",
|
||||
"imageLoadError": "فشل تحميل الصورة",
|
||||
"imagePlaceholder": "لا توجد صورة متاحة",
|
||||
"infoUnavailable": "تحميل بنقرة واحدة (المعلومات غير متاحة)",
|
||||
"loading": "جاري التحميل",
|
||||
"moreOptions": "خيارات إضافية",
|
||||
"noActiveDownloads": "لا توجد تحميلات نشطة",
|
||||
"noAudio": "لا يوجد صوت",
|
||||
"noHistory": "لا يوجد سجل تحميل",
|
||||
"noItems": "لم يتم العثور على عناصر",
|
||||
"goToSettings": "انتقل إلى الإعدادات",
|
||||
"oneClickDownload": "تحميل بنقرة واحدة",
|
||||
"oneClickDownloadDescription": "تحميل مباشر بالإعدادات الافتراضية دون تأكيد",
|
||||
"oneClickDownloadEnabled": "تم تفعيل التحميل بنقرة واحدة. ستبدأ التحميلات مباشرة بالإعدادات الافتراضية.",
|
||||
"oneClickDownloadNow": "تحميل الآن",
|
||||
"oneClickDownloadStarted": "بدأ التحميل بالإعدادات الافتراضية",
|
||||
"paste": "لصق",
|
||||
"pastePlaylistUrl": "انقر للصق رابط قائمة التشغيل من الحافظة [Ctrl + V]",
|
||||
"pasteUrl": "انقر للصق رابط أو معرف الفيديو [Ctrl + V]",
|
||||
"preparing": "جاري التحضير...",
|
||||
"processing": "جاري المعالجة",
|
||||
"progress": "التقدم",
|
||||
"showDetails": "إظهار التفاصيل",
|
||||
"hideDetails": "إخفاء التفاصيل",
|
||||
"selectAudioFormat": "اختر تنسيق الصوت",
|
||||
"selectFormat": "اختر التنسيق",
|
||||
"selectVideoFormat": "اختر تنسيق الفيديو",
|
||||
"startDownload": "بدء التحميل",
|
||||
"singleVideo": "فيديو واحد",
|
||||
"speed": "السرعة",
|
||||
"title": "العنوان",
|
||||
"total": "الإجمالي",
|
||||
"unknownQuality": "جودة غير معروفة",
|
||||
"unknownSize": "حجم غير معروف",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "فيديو",
|
||||
"videoInfo": "معلومات الفيديو",
|
||||
"videoInfoUpdated": "تم تحديث معلومات الفيديو",
|
||||
"metadata": {
|
||||
"source": "المصدر",
|
||||
"playlist": "قائمة التشغيل",
|
||||
"format": "التنسيق",
|
||||
"quality": "الجودة",
|
||||
"codec": "الترميز",
|
||||
"savedFile": "الملف المحفوظ",
|
||||
"url": "رابط المصدر",
|
||||
"description": "الوصف",
|
||||
"views": "المشاهدات",
|
||||
"tags": "العلامات",
|
||||
"downloadPath": "مسار التحميل",
|
||||
"createdAt": "تم الإنشاء في",
|
||||
"startedAt": "بدأ في",
|
||||
"completedAt": "اكتمل في",
|
||||
"speed": "السرعة",
|
||||
"fileSize": "حجم الملف",
|
||||
"width": "العرض",
|
||||
"height": "الارتفاع",
|
||||
"fps": "معدل الإطارات",
|
||||
"videoCodec": "ترميز الفيديو",
|
||||
"audioCodec": "ترميز الصوت",
|
||||
"formatNote": "ملاحظة التنسيق",
|
||||
"protocol": "البروتوكول",
|
||||
"subscription": "الاشتراك"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "انقر لنسخ التفاصيل",
|
||||
"clipboardEmpty": "الحافظة فارغة",
|
||||
"downloadFailed": "فشل التحميل",
|
||||
"downloadNecessaryFilesFailed": "فشل في تحميل الملفات الضرورية. يرجى التحقق من شبكتك والمحاولة مرة أخرى",
|
||||
"emptyUrl": "يرجى إدخال رابط",
|
||||
"errorDetails": "تفاصيل الخطأ",
|
||||
"fetchInfoFailed": "فشل في جلب معلومات الفيديو",
|
||||
"networkError": "حدث خطأ ما. تحقق من شبكتك واستخدم رابطاً صحيحاً",
|
||||
"pasteFromClipboard": "فشل في اللصق من الحافظة"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "مسح الملغاة",
|
||||
"clearCompleted": "مسح المكتملة",
|
||||
"clearErrors": "مسح الأخطاء",
|
||||
"copyToClipboard": "نسخ إلى الحافظة",
|
||||
"copyUrl": "نسخ الرابط",
|
||||
"date": "التاريخ",
|
||||
"description": "عرض وإدارة سجل التحميل الخاص بك",
|
||||
"duration": "المدة",
|
||||
"fileSize": "حجم الملف",
|
||||
"filters": {
|
||||
"all": "الكل",
|
||||
"cancelled": "ملغي",
|
||||
"completed": "مكتمل",
|
||||
"errors": "أخطاء"
|
||||
},
|
||||
"noHistory": "لا يوجد سجل تحميل بعد",
|
||||
"noHistoryDescription": "ستظهر تحميلاتك المكتملة هنا",
|
||||
"openDownloadFolder": "فتح مجلد التحميل",
|
||||
"openFile": "فتح الملف",
|
||||
"openFileLocation": "فتح موقع الملف",
|
||||
"openFolder": "فتح المجلد",
|
||||
"openInBrowser": "انقر لفتح في المتصفح",
|
||||
"removeItem": "إزالة العنصر",
|
||||
"stats": {
|
||||
"cancelled": "ملغي",
|
||||
"completed": "مكتمل",
|
||||
"errors": "أخطاء",
|
||||
"total": "الإجمالي"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "ملغي",
|
||||
"completed": "مكتمل",
|
||||
"error": "خطأ"
|
||||
},
|
||||
"title": "سجل التحميل"
|
||||
},
|
||||
"menu": {
|
||||
"about": "حول",
|
||||
"download": "تحميل",
|
||||
"playlist": "تحميل قائمة التشغيل",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "الاشتراكات",
|
||||
"preferences": "التفضيلات",
|
||||
"supportedSites": "المواقع المدعومة",
|
||||
"theme": "المظهر:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "فشل في النسخ إلى الحافظة",
|
||||
"downloadCompleted": "اكتمل التحميل",
|
||||
"downloadFailed": "فشل التحميل",
|
||||
"downloadStarted": "بدأ التحميل",
|
||||
"itemRemoved": "تم إزالة العنصر",
|
||||
"openFileFailed": "فشل في فتح الملف",
|
||||
"openFolderFailed": "فشل في فتح المجلد",
|
||||
"removeFailed": "فشل في إزالة العنصر",
|
||||
"settingsSaved": "تم حفظ الإعدادات",
|
||||
"urlCopied": "تم نسخ الرابط إلى الحافظة",
|
||||
"videoCopied": "تم نسخ الفيديو إلى الحافظة"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "قائمة التشغيل",
|
||||
"clearPreview": "مسح المعاينة",
|
||||
"comingSoon": "ميزة تحميل قائمة التشغيل قريباً!",
|
||||
"completed": "تم تحميل قائمة التشغيل",
|
||||
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
|
||||
"downloadFailed": "فشل في بدء تحميل قائمة التشغيل",
|
||||
"downloadPlaylist": "تحميل قائمة التشغيل",
|
||||
"downloadStarted": "بدأ تحميل {{count}} فيديو من قائمة التشغيل",
|
||||
"downloadType": "نوع التحميل",
|
||||
"downloading": "جاري تحميل قائمة التشغيل:",
|
||||
"endIndex": "النهاية",
|
||||
"enterPlaylistUrl": "أدخل رابط قائمة التشغيل",
|
||||
"fetchFailed": "فشل في جلب معلومات قائمة التشغيل",
|
||||
"filenameFormat": "تنسيق اسم الملف لقوائم التشغيل",
|
||||
"folderFormat": "تنسيق اسم المجلد لقوائم التشغيل",
|
||||
"foundVideos": "تم العثور على {{count}} فيديو في قائمة التشغيل",
|
||||
"groupActive": "{{count}} نشط",
|
||||
"groupErrors": "{{count}} فشل",
|
||||
"groupSummary": "{{completed}} / {{total}} مكتمل",
|
||||
"linkLabel": "رابط قائمة التشغيل",
|
||||
"noEntries": "لم يتم العثور على فيديوهات في قائمة التشغيل هذه",
|
||||
"noEntriesInRange": "لا توجد فيديوهات في النطاق المحدد",
|
||||
"noRangeSelected": "لم يتم تعيين النهاية - تم تحديد قائمة التشغيل الكاملة",
|
||||
"playlistUrlDescription": "تحميل جميع الفيديوهات من قائمة تشغيل بشكل مجمع",
|
||||
"positionLabel": "العنصر {{index}} من {{total}}",
|
||||
"previewButton": "معاينة قائمة التشغيل",
|
||||
"previewFailed": "فشل في معاينة قائمة التشغيل",
|
||||
"previewSummary": "معاينة عناصر قائمة التشغيل قبل التحميل.",
|
||||
"previewRequired": "معاينة قائمة التشغيل قبل التحميل.",
|
||||
"range": "النطاق (اختياري)",
|
||||
"resetToDefault": "إعادة تعيين إلى الافتراضي",
|
||||
"selectedRange": "النطاق: {{start}}-{{end}}",
|
||||
"showingCount": "عرض {{count}} فيديو",
|
||||
"startIndex": "البداية (1)",
|
||||
"title": "تحميل قائمة التشغيل",
|
||||
"totalVideos": "إجمالي الفيديوهات: {{count}}",
|
||||
"untitled": "قائمة تشغيل بدون عنوان"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "حول",
|
||||
"advanced": "متقدم",
|
||||
"app": "إعدادات التطبيق",
|
||||
"audio": "تفضيلات الصوت",
|
||||
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
|
||||
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
|
||||
"cookiesFile": "ملف ملفات تعريف الارتباط",
|
||||
"cookiesFileDescription": "ملف ملفات تعريف الارتباط بتنسيق Netscape للتحميل للمصادقة",
|
||||
"clearCookiesFile": "مسح",
|
||||
"cookiesHelpTitle": "استخدام ملفات تعريف الارتباط",
|
||||
"cookiesHelpBrowser": "اختر متصفحك أعلاه لإعادة استخدام جلسته الموقعة تلقائياً.",
|
||||
"cookiesHelpFile": "قم بتصدير ملف ملفات تعريف الارتباط Netscape (راجع الأسئلة الشائعة لـ yt-dlp) واختره هنا عند الحاجة.",
|
||||
"cookiesHelpFaq": "فتح الأسئلة الشائعة لملفات تعريف الارتباط في yt-dlp",
|
||||
"openLinkError": "فشل في فتح الرابط",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "استخدام ملف التكوين",
|
||||
"configFileDescription": "ملف تكوين مخصص لـ yt-dlp",
|
||||
"clearConfigFile": "مسح",
|
||||
"dark": "داكن",
|
||||
"description": "تكوين تفضيلات التحميل وإعدادات التطبيق",
|
||||
"directorySelectError": "فشل في اختيار المجلد",
|
||||
"downloadPath": "موقع التحميل",
|
||||
"downloadPathDescription": "اختر مكان حفظ الملفات المحملة",
|
||||
"fileSelectError": "فشل في اختيار الملف",
|
||||
"general": "عام",
|
||||
"language": "اللغة",
|
||||
"light": "فاتح",
|
||||
"hideDockIcon": "إخفاء أيقونة Dock",
|
||||
"hideDockIconDescription": "إزالة VidBee من Dock في macOS. استخدم شريط القائمة أو أيقونة الدرج لإعادة فتح التطبيق.",
|
||||
"launchAtLogin": "بدء التشغيل عند تسجيل الدخول",
|
||||
"launchAtLoginDescription": "فتح VidBee تلقائياً بعد تسجيل الدخول إلى جهاز الكمبيوتر الخاص بك.",
|
||||
"launchAtLoginUnsupported": "البدء التلقائي متاح فقط على macOS و Windows.",
|
||||
"enableAnalytics": "مساعدة في تحسين VidBee",
|
||||
"enableAnalyticsDescription": "مشاركة بيانات الاستخدام المجهولة لمساعدتنا في فهم كيفية استخدام التطبيق وأولويات التحسينات.",
|
||||
"maxConcurrentDownloads": "العدد الأقصى للتحميلات النشطة",
|
||||
"maxConcurrentDownloadsDescription": "العدد الأقصى للتحميلات المتزامنة",
|
||||
"none": "لا شيء",
|
||||
"oneClickDownload": "تحميل بنقرة واحدة",
|
||||
"oneClickDownloadDescription": "تفعيل التحميل بنقرة واحدة بالإعدادات الافتراضية",
|
||||
"oneClickDownloadType": "نوع التحميل الافتراضي",
|
||||
"oneClickDownloadTypeDescription": "اختر نوع التحميل الافتراضي للتحميلات بنقرة واحدة. تستخدم الجودة الإعداد المسبق أدناه.",
|
||||
"oneClickQuality": "الجودة المفضلة",
|
||||
"oneClickQualityDescription": "اختر الإعداد المسبق للجودة المستخدم للتحميلات بنقرة واحدة",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "تلقائي",
|
||||
"bad": "سيء",
|
||||
"best": "أفضل",
|
||||
"good": "جيد",
|
||||
"normal": "عادي",
|
||||
"worst": "أسوأ"
|
||||
},
|
||||
"proxy": "الوكيل",
|
||||
"proxyDescription": "خادم الوكيل لطلبات الشبكة",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "اختر ملف التكوين",
|
||||
"selectPath": "اختر",
|
||||
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
|
||||
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
|
||||
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
|
||||
},
|
||||
"system": "النظام",
|
||||
"theme": "المظهر",
|
||||
"themeDescription": "اختر مظهراً فاتحاً أو داكناً أو نظامياً لـ VidBee",
|
||||
"title": "الإعدادات",
|
||||
"tray": {
|
||||
"quit": "إنهاء",
|
||||
"showHome": "إظهار الصفحة الرئيسية"
|
||||
},
|
||||
"video": "تفضيلات الفيديو"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "الاشتراكات",
|
||||
"subtitle": "{{count}} اشتراك{{count, plural, one {} other {}}}",
|
||||
"description": "مراقبة تغذيات RSS تلقائياً ووضع التحميلات الجديدة في قائمة الانتظار دون عمل يدوي.",
|
||||
"defaults": {
|
||||
"title": "الإعدادات الافتراضية للأتمتة",
|
||||
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
|
||||
"downloadDirectory": "مجلد التحميل",
|
||||
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
|
||||
"checkInterval": "فترة الفحص (ساعات)",
|
||||
"onlyLatest": "تحميل أحدث فيديو فقط",
|
||||
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
|
||||
},
|
||||
"add": {
|
||||
"title": "إضافة RSS",
|
||||
"description": "الصق رابط تغذية RSS. سيكتشف VidBee التغذية تلقائياً."
|
||||
},
|
||||
"fields": {
|
||||
"url": "رابط التغذية",
|
||||
"keywords": "مرشح الكلمات الرئيسية (مفصولة بفواصل)",
|
||||
"tags": "علامات تلقائية",
|
||||
"customDirectory": "مجلد مخصص",
|
||||
"namingTemplate": "قالب اسم ملف مخصص (الملف فقط)",
|
||||
"onlyLatest": "تحميل أحدث فيديو فقط",
|
||||
"onlyLatestDescription": "تجاهل عناصر المتراكمة وجلب أحدث تحميل من هذه التغذية فقط.",
|
||||
"enabled": "مفعل",
|
||||
"disabled": "معطل",
|
||||
"onlyLatestShort": "الأحدث فقط"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "إضافة",
|
||||
"refresh": "تحديث",
|
||||
"edit": "تعديل",
|
||||
"remove": "إزالة",
|
||||
"save": "حفظ التغييرات",
|
||||
"selectDirectory": "تصفح",
|
||||
"enable": "تفعيل",
|
||||
"disable": "تعطيل"
|
||||
},
|
||||
"items": {
|
||||
"title": "آخر التحميلات ({{count}})",
|
||||
"count": "{{count}} عنصر",
|
||||
"empty": "لم يتم العثور على عناصر تغذية حديثة.",
|
||||
"status": {
|
||||
"queued": "في قائمة الانتظار",
|
||||
"notQueued": "ليس في قائمة الانتظار",
|
||||
"pending": "قيد الانتظار",
|
||||
"downloading": "جاري التحميل",
|
||||
"processing": "جاري المعالجة",
|
||||
"completed": "مكتمل",
|
||||
"error": "فشل",
|
||||
"cancelled": "ملغي"
|
||||
},
|
||||
"fromChannel": "من {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "حالة التحميل: {{status}}",
|
||||
"downloadPending": "في انتظار تفاصيل التحميل...",
|
||||
"notQueued": "ليس في قائمة انتظار التحميل بعد"
|
||||
},
|
||||
"actions": {
|
||||
"open": "فتح في المتصفح",
|
||||
"queue": "إضافة إلى قائمة انتظار التحميل"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "الاشتراك",
|
||||
"unknown": "اشتراك غير معروف",
|
||||
"noThumbnail": "لا توجد صورة مصغرة"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "فشل في فتح منتقي المجلد.",
|
||||
"missingUrl": "يرجى لصق رابط القناة أولاً.",
|
||||
"created": "تمت إضافة الاشتراك",
|
||||
"createError": "فشل في إضافة الاشتراك.",
|
||||
"refreshStarted": "بدأ التحديث",
|
||||
"removed": "تمت إزالة الاشتراك",
|
||||
"updated": "تم تحديث الاشتراك",
|
||||
"itemQueued": "تمت الإضافة إلى قائمة انتظار التحميل",
|
||||
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
|
||||
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
|
||||
"openLinkError": "فشل في فتح رابط الفيديو.",
|
||||
"resolveError": "فشل في حل رابط تغذية RSS."
|
||||
},
|
||||
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
|
||||
"detecting": "جاري اكتشاف التغذية...",
|
||||
"latestVideo": "أحدث فيديو: {{title}}",
|
||||
"lastChecked": "آخر فحص: {{time}}",
|
||||
"never": "أبداً",
|
||||
"empty": "لا توجد اشتراكات بعد. أضف قنواتك المفضلة لبدء التحميل التلقائي.",
|
||||
"edit": {
|
||||
"title": "تعديل {{name}}",
|
||||
"description": "ضبط المرشحات والعلامات والتجاوزات لهذه التغذية."
|
||||
},
|
||||
"status": {
|
||||
"title": "الحالة",
|
||||
"up-to-date": "محدث",
|
||||
"checking": "جاري الفحص",
|
||||
"failed": "فشل",
|
||||
"idle": "خامل",
|
||||
"tooltip": {
|
||||
"updatedAt": "محدث: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "الاشتراكات الآلية مع RSSHub",
|
||||
"description": "اجمع VidBee مع RSSHub لتمكين الاشتراكات والتحميلات الآلية من منصات مختلفة. بمجرد الإعداد، يعمل VidBee في الخلفية ويحمل تلقائياً أحدث الفيديوهات والمحتوى.",
|
||||
"learnMore": "تعرف على المزيد حول RSSHub",
|
||||
"openDocs": "فتح وثائق RSSHub",
|
||||
"hint": "ليس لديك رابط تغذية RSS؟ استخدم RSSHub لإنشاء تغذيات RSS لـ YouTube و Twitter والآلاف من المنصات الأخرى."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "يدعم {{sites}} والمزيد.",
|
||||
"moreDescription": "يتم تحديث قائمة yt-dlp الكاملة باستمرار من قبل المجتمع.",
|
||||
"moreTitle": "تحتاج موقعاً آخر؟",
|
||||
"openFullList": "فتح قائمة المواقع المدعومة الكاملة",
|
||||
"pageDescription": "يستخدم VidBee yt-dlp تحت الغطاء للوصول إلى مئات المصادر.",
|
||||
"pageIntro": "إليك الخدمات الرئيسية التي يقوم الناس بتحميلها منها في أغلب الأحيان.",
|
||||
"pageTitle": "المواقع المدعومة",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "ألبومات الفنانين المستقلين وإصدارات المجتمع.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "الأخبار العالمية والرياضة ومقاطع الترفيه.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "مقاطع فيديو Feed و Watch و Reels من الصفحات العامة.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "محتوى Feed و Stories و Reels و Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "البث المباشر للمبدعين وإعادة التشغيل على منصة Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "المحادثات المهنية والندوات عبر الويب ومقاطع الفيديو التعليمية.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "مزج DJ وبرامج الراديو والصوت طويل الشكل.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "أرشيف الرسوم المتحركة اليابانية والموسيقى والبث المباشر.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "دبابيس الأفكار ومقاطع كيفية القيام بذلك ومقاطع فيديو إلهام نمط الحياة.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "المقاطع المضمنة ومقاطع الفيديو المستضافة من المجتمعات.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "مقاطع الموسيقى وقوائم التشغيل ومجموعات DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "مقاطع فيديو قصيرة للهاتف المحمول والتأثيرات والبث المباشر.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "الوسائط الإبداعية قصيرة الشكل وتحريرات المعجبين.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "البث المباشر للألعاب والموسيقى و IRL و VODs.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "منشورات الجدول الزمني وتسجيلات Spaces والبث.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "استضافة فيديو عالية الجودة للمبدعين والأعمال.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "فيديو طويل الشكل والبث المباشر من المبدعين في جميع أنحاء العالم.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "مقاطع فيديو موسيقية رسمية وألبومات وعروض مباشرة.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "المنصات الرئيسية",
|
||||
"viewAll": "عرض جميع المواقع المدعومة"
|
||||
}
|
||||
}
|
||||
586
src/renderer/src/locales/de.json
Normal file
586
src/renderer/src/locales/de.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"download": "Herunterladen",
|
||||
"email": "E-Mail",
|
||||
"feedback": "Feedback",
|
||||
"goToDownload": "Zur Download-Seite gehen",
|
||||
"openRepo": "GitHub-Repository öffnen",
|
||||
"view": "Ansehen",
|
||||
"visit": "Besuchen"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Neue Versionen automatisch im Hintergrund herunterladen und installieren.",
|
||||
"autoUpdateTitle": "Automatische Updates",
|
||||
"betaProgramDescription": "Erhalten Sie frühe Builds und kommende Funktionen vor allen anderen.",
|
||||
"betaProgramTitle": "Vorschau-Kanal",
|
||||
"description": "VidBee ist ein kostenloser, quelloffener Downloader, der mit Electron erstellt wurde und von yt-dlp angetrieben wird.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Folgen Sie @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Bleiben Sie auf dem Laufenden mit den neuesten VidBee-Nachrichten und Updates.",
|
||||
"followAuthorSupport": "Folgen Sie dem Entwickler auf X (Twitter), um die neuesten Updates und Nachrichten über VidBee zu erhalten.",
|
||||
"followAuthorTitle": "Dem Entwickler folgen",
|
||||
"here": "hier",
|
||||
"homepage": "Startseite",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Suche nach Updates...",
|
||||
"downloadError": "Update konnte nicht heruntergeladen werden",
|
||||
"downloadStarted": "Download gestartet...",
|
||||
"downloadUpdate": "Update {{version}} herunterladen und installieren?",
|
||||
"manualDownloadAction": "Jetzt herunterladen",
|
||||
"noUpdatesAvailable": "Sie verwenden die neueste Version",
|
||||
"restartToUpdate": "Jetzt neu starten, um Update zu installieren?",
|
||||
"restartNowAction": "Jetzt neu starten",
|
||||
"updateAvailable": "Update verfügbar: {{version}}",
|
||||
"updateAvailableMessage": "Eine neue Version {{version}} ist verfügbar. Bitte laden Sie sie von der offiziellen Website herunter.",
|
||||
"updateDownloaded": "Update heruntergeladen, neu starten zum Installieren",
|
||||
"updateDownloadedVersion": "Update {{version}} heruntergeladen, neu starten zum Installieren",
|
||||
"updateError": "Update-Prüfung fehlgeschlagen: {{error}}",
|
||||
"unknownErrorFallback": "Unbekannter Fehler"
|
||||
},
|
||||
"preferencesDescription": "Update-Einstellungen anpassen, ohne diese Seite zu verlassen.",
|
||||
"preferencesTitle": "Schnellumschalter",
|
||||
"resources": {
|
||||
"changelog": "Versionshinweise",
|
||||
"changelogDescription": "Informieren Sie sich über die Änderungen in jeder Version.",
|
||||
"contact": "E-Mail-Support",
|
||||
"contactDescription": "Kontaktieren Sie uns direkt für Hilfe oder Zusammenarbeit.",
|
||||
"documentation": "Hilfezentrum",
|
||||
"documentationDescription": "Anleitungen, FAQs und gängige Arbeitsabläufe.",
|
||||
"feedback": "Feedback & Probleme",
|
||||
"feedbackDescription": "Teilen Sie Ideen oder melden Sie Probleme auf GitHub.",
|
||||
"license": "Lizenz",
|
||||
"licenseDescription": "Überprüfen Sie die Bedingungen der Open-Source-Lizenz.",
|
||||
"website": "Offizielle Website",
|
||||
"websiteDescription": "Produkthighlights, Roadmap und Community-Nachrichten."
|
||||
},
|
||||
"resourcesDescription": "Nützliche Links, um mehr über VidBee zu erfahren und in Verbindung zu bleiben.",
|
||||
"resourcesTitle": "Ressourcen",
|
||||
"shareActions": {
|
||||
"copy": "Link kopieren",
|
||||
"facebook": "Auf Facebook teilen",
|
||||
"twitter": "Auf X (Twitter) teilen"
|
||||
},
|
||||
"shareDescription": "Teilen Sie VidBee mit Ihrer Community mit einem Klick.",
|
||||
"shareSupport": "Empfehlen Sie VidBee Ihren Freunden, um unser Wachstum und unsere Updates zu unterstützen.",
|
||||
"shareTitle": "Verbreiten Sie die Nachricht",
|
||||
"sourceCode": "Quellcode ist verfügbar",
|
||||
"title": "Über",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Neueste: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Neue Version verfügbar",
|
||||
"uptodate": "Sie sind auf dem neuesten Stand",
|
||||
"error": "Neueste Version konnte nicht abgerufen werden"
|
||||
},
|
||||
"downloadingUpdate": "Update wird heruntergeladen"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "App schließen, wenn Download abgeschlossen ist",
|
||||
"currentLocation": "Aktueller Download-Speicherort - ",
|
||||
"downloadLocation": "Download-Speicherort",
|
||||
"downloadSubs": "Untertitel herunterladen, falls verfügbar",
|
||||
"end": "Ende",
|
||||
"endHint": "Wenn leer gelassen, wird bis zum Ende heruntergeladen",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Download-Speicherort auswählen",
|
||||
"start": "Start",
|
||||
"startHint": "Wenn leer gelassen, wird vom Anfang an gestartet",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Untertitel",
|
||||
"timeRange": "Bestimmten Zeitbereich herunterladen",
|
||||
"title": "Erweiterte Optionen"
|
||||
},
|
||||
"app": {
|
||||
"description": "Videos und Audios von Hunderten von Websites herunterladen",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Schlecht",
|
||||
"best": "Am besten",
|
||||
"extract": "Extrahieren",
|
||||
"good": "Gut",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Format auswählen",
|
||||
"selectQuality": "Qualität auswählen",
|
||||
"title": "Audio extrahieren",
|
||||
"worst": "Am schlechtesten"
|
||||
},
|
||||
"download": {
|
||||
"active": "Aktiv",
|
||||
"all": "Alle",
|
||||
"audio": "Audio",
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"cancelled": "Abgebrochen",
|
||||
"clearCompleted": "Abgeschlossene löschen",
|
||||
"clearDownloads": "Downloads löschen",
|
||||
"completed": "Abgeschlossen",
|
||||
"downloadAudio": "Audio herunterladen",
|
||||
"downloadBtn": "Herunterladen",
|
||||
"downloadPending": "Ausstehend",
|
||||
"downloadQueue": "Download-Warteschlange",
|
||||
"downloadVideo": "Video herunterladen",
|
||||
"downloading": "Wird heruntergeladen...",
|
||||
"enterUrl": "Video-URL eingeben",
|
||||
"enterUrlDescription": "Fügen Sie eine Video-URL ein oder geben Sie sie ein. ",
|
||||
"error": "Fehler",
|
||||
"fetch": "Abrufen",
|
||||
"fetchingVideoInfo": "Video-Informationen werden abgerufen...",
|
||||
"history": "Verlauf",
|
||||
"imageLoadError": "Bild konnte nicht geladen werden",
|
||||
"imagePlaceholder": "Kein Bild verfügbar",
|
||||
"infoUnavailable": "Ein-Klick-Download (Info nicht verfügbar)",
|
||||
"loading": "Wird geladen",
|
||||
"moreOptions": "Weitere Optionen",
|
||||
"noActiveDownloads": "Keine aktiven Downloads",
|
||||
"noAudio": "Kein Audio",
|
||||
"noHistory": "Kein Download-Verlauf",
|
||||
"noItems": "Keine Elemente gefunden",
|
||||
"goToSettings": "Zu Einstellungen gehen",
|
||||
"oneClickDownload": "Ein-Klick-Download",
|
||||
"oneClickDownloadDescription": "Direkt mit Standardeinstellungen ohne Bestätigung herunterladen",
|
||||
"oneClickDownloadEnabled": "Ein-Klick-Download ist aktiviert. Downloads starten direkt mit Standardeinstellungen.",
|
||||
"oneClickDownloadNow": "Jetzt herunterladen",
|
||||
"oneClickDownloadStarted": "Download mit Standardeinstellungen gestartet",
|
||||
"paste": "Einfügen",
|
||||
"pastePlaylistUrl": "Klicken, um Playlist-Link aus Zwischenablage einzufügen [Strg + V]",
|
||||
"pasteUrl": "Klicken, um Video-URL oder ID einzufügen [Strg + V]",
|
||||
"preparing": "Wird vorbereitet...",
|
||||
"processing": "Wird verarbeitet",
|
||||
"progress": "Fortschritt",
|
||||
"showDetails": "Details anzeigen",
|
||||
"hideDetails": "Details ausblenden",
|
||||
"selectAudioFormat": "Audio-Format auswählen",
|
||||
"selectFormat": "Format auswählen",
|
||||
"selectVideoFormat": "Video-Format auswählen",
|
||||
"startDownload": "Download starten",
|
||||
"singleVideo": "Einzelnes Video",
|
||||
"speed": "Geschwindigkeit",
|
||||
"title": "Titel",
|
||||
"total": "Gesamt",
|
||||
"unknownQuality": "Unbekannte Qualität",
|
||||
"unknownSize": "Unbekannte Größe",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Video-Informationen",
|
||||
"videoInfoUpdated": "Video-Informationen aktualisiert",
|
||||
"metadata": {
|
||||
"source": "Quelle",
|
||||
"playlist": "Playlist",
|
||||
"format": "Format",
|
||||
"quality": "Qualität",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Gespeicherte Datei",
|
||||
"url": "Quell-URL",
|
||||
"description": "Beschreibung",
|
||||
"views": "Aufrufe",
|
||||
"tags": "Tags",
|
||||
"downloadPath": "Download-Pfad",
|
||||
"createdAt": "Erstellt am",
|
||||
"startedAt": "Gestartet am",
|
||||
"completedAt": "Abgeschlossen am",
|
||||
"speed": "Geschwindigkeit",
|
||||
"fileSize": "Dateigröße",
|
||||
"width": "Breite",
|
||||
"height": "Höhe",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Video-Codec",
|
||||
"audioCodec": "Audio-Codec",
|
||||
"formatNote": "Format-Hinweis",
|
||||
"protocol": "Protokoll",
|
||||
"subscription": "Abonnement"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Klicken, um Details zu kopieren",
|
||||
"clipboardEmpty": "Zwischenablage ist leer",
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadNecessaryFilesFailed": "Herunterladen der erforderlichen Dateien fehlgeschlagen. Bitte überprüfen Sie Ihr Netzwerk und versuchen Sie es erneut",
|
||||
"emptyUrl": "Bitte geben Sie eine URL ein",
|
||||
"errorDetails": "Fehlerdetails",
|
||||
"fetchInfoFailed": "Video-Informationen konnten nicht abgerufen werden",
|
||||
"networkError": "Ein Fehler ist aufgetreten. Überprüfen Sie Ihr Netzwerk und verwenden Sie die richtige URL",
|
||||
"pasteFromClipboard": "Einfügen aus Zwischenablage fehlgeschlagen"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Abgebrochene löschen",
|
||||
"clearCompleted": "Abgeschlossene löschen",
|
||||
"clearErrors": "Fehler löschen",
|
||||
"copyToClipboard": "In Zwischenablage kopieren",
|
||||
"copyUrl": "URL kopieren",
|
||||
"date": "Datum",
|
||||
"description": "Ihren Download-Verlauf anzeigen und verwalten",
|
||||
"duration": "Dauer",
|
||||
"fileSize": "Dateigröße",
|
||||
"filters": {
|
||||
"all": "Alle",
|
||||
"cancelled": "Abgebrochen",
|
||||
"completed": "Abgeschlossen",
|
||||
"errors": "Fehler"
|
||||
},
|
||||
"noHistory": "Noch kein Download-Verlauf",
|
||||
"noHistoryDescription": "Ihre abgeschlossenen Downloads werden hier angezeigt",
|
||||
"openDownloadFolder": "Download-Ordner öffnen",
|
||||
"openFile": "Datei öffnen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"openFolder": "Ordner öffnen",
|
||||
"openInBrowser": "Klicken, um im Browser zu öffnen",
|
||||
"removeItem": "Element entfernen",
|
||||
"stats": {
|
||||
"cancelled": "Abgebrochen",
|
||||
"completed": "Abgeschlossen",
|
||||
"errors": "Fehler",
|
||||
"total": "Gesamt"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Abgebrochen",
|
||||
"completed": "Abgeschlossen",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"title": "Download-Verlauf"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Über",
|
||||
"download": "Herunterladen",
|
||||
"playlist": "Playlist herunterladen",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Abonnements",
|
||||
"preferences": "Einstellungen",
|
||||
"supportedSites": "Unterstützte Websites",
|
||||
"theme": "Design:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Kopieren in Zwischenablage fehlgeschlagen",
|
||||
"downloadCompleted": "Download abgeschlossen",
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet",
|
||||
"itemRemoved": "Element entfernt",
|
||||
"openFileFailed": "Datei konnte nicht geöffnet werden",
|
||||
"openFolderFailed": "Ordner konnte nicht geöffnet werden",
|
||||
"removeFailed": "Element konnte nicht entfernt werden",
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"urlCopied": "URL in Zwischenablage kopiert",
|
||||
"videoCopied": "Video in Zwischenablage kopiert"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Vorschau löschen",
|
||||
"comingSoon": "Playlist-Download-Funktion kommt bald!",
|
||||
"completed": "Playlist heruntergeladen",
|
||||
"description": "Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen",
|
||||
"downloadFailed": "Playlist-Download konnte nicht gestartet werden",
|
||||
"downloadPlaylist": "Playlist herunterladen",
|
||||
"downloadStarted": "Download von {{count}} Videos aus Playlist gestartet",
|
||||
"downloadType": "Download-Typ",
|
||||
"downloading": "Playlist wird heruntergeladen:",
|
||||
"endIndex": "Ende",
|
||||
"enterPlaylistUrl": "Playlist-URL eingeben",
|
||||
"fetchFailed": "Playlist-Informationen konnten nicht abgerufen werden",
|
||||
"filenameFormat": "Dateinamenformat für Playlists",
|
||||
"folderFormat": "Ordnernamenformat für Playlists",
|
||||
"foundVideos": "{{count}} Videos in Playlist gefunden",
|
||||
"groupActive": "{{count}} aktiv",
|
||||
"groupErrors": "{{count}} fehlgeschlagen",
|
||||
"groupSummary": "{{completed}} / {{total}} abgeschlossen",
|
||||
"linkLabel": "Playlist-URL",
|
||||
"noEntries": "In dieser Playlist wurden keine Videos gefunden",
|
||||
"noEntriesInRange": "Keine Videos im ausgewählten Bereich",
|
||||
"noRangeSelected": "Kein Ende gesetzt - vollständige Playlist ausgewählt",
|
||||
"playlistUrlDescription": "Alle Videos aus einer Playlist in großen Mengen herunterladen",
|
||||
"positionLabel": "Element {{index}} von {{total}}",
|
||||
"previewButton": "Playlist-Vorschau",
|
||||
"previewFailed": "Playlist-Vorschau fehlgeschlagen",
|
||||
"previewSummary": "Playlist-Elemente vor dem Download in der Vorschau anzeigen.",
|
||||
"previewRequired": "Playlist vor dem Download in der Vorschau anzeigen.",
|
||||
"range": "Bereich (Optional)",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"selectedRange": "Bereich: {{start}}-{{end}}",
|
||||
"showingCount": "{{count}} Videos werden angezeigt",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Playlist herunterladen",
|
||||
"totalVideos": "Gesamt Videos: {{count}}",
|
||||
"untitled": "Unbenannte Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Über",
|
||||
"advanced": "Erweitert",
|
||||
"app": "App-Einstellungen",
|
||||
"audio": "Audio-Einstellungen",
|
||||
"browserForCookies": "Browser für Cookies auswählen",
|
||||
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
|
||||
"cookiesFile": "Cookie-Datei",
|
||||
"cookiesFileDescription": "Netscape-formatierte Cookie-Datei zum Laden für die Authentifizierung",
|
||||
"clearCookiesFile": "Löschen",
|
||||
"cookiesHelpTitle": "Cookies verwenden",
|
||||
"cookiesHelpBrowser": "Wählen Sie oben Ihren Browser aus, um seine angemeldete Sitzung automatisch wiederzuverwenden.",
|
||||
"cookiesHelpFile": "Exportieren Sie eine Netscape-Cookie-Datei (siehe yt-dlp FAQ) und wählen Sie sie hier bei Bedarf aus.",
|
||||
"cookiesHelpFaq": "yt-dlp Cookie FAQ öffnen",
|
||||
"openLinkError": "Link konnte nicht geöffnet werden",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Konfigurationsdatei verwenden",
|
||||
"configFileDescription": "Benutzerdefinierte Konfigurationsdatei für yt-dlp",
|
||||
"clearConfigFile": "Löschen",
|
||||
"dark": "Dunkel",
|
||||
"description": "Konfigurieren Sie Ihre Download-Einstellungen und Anwendungseinstellungen",
|
||||
"directorySelectError": "Verzeichnis konnte nicht ausgewählt werden",
|
||||
"downloadPath": "Download-Speicherort",
|
||||
"downloadPathDescription": "Wählen Sie, wo heruntergeladene Dateien gespeichert werden sollen",
|
||||
"fileSelectError": "Datei konnte nicht ausgewählt werden",
|
||||
"general": "Allgemein",
|
||||
"language": "Sprache",
|
||||
"light": "Hell",
|
||||
"hideDockIcon": "Dock-Symbol ausblenden",
|
||||
"hideDockIconDescription": "VidBee aus dem macOS Dock entfernen. Verwenden Sie die Menüleiste oder das Tray-Symbol, um die App erneut zu öffnen.",
|
||||
"launchAtLogin": "Beim Start starten",
|
||||
"launchAtLoginDescription": "VidBee automatisch öffnen, nachdem Sie sich bei Ihrem Computer angemeldet haben.",
|
||||
"launchAtLoginUnsupported": "Autostart ist nur unter macOS und Windows verfügbar.",
|
||||
"enableAnalytics": "Helfen Sie, VidBee zu verbessern",
|
||||
"enableAnalyticsDescription": "Teilen Sie anonyme Nutzungsdaten, damit wir verstehen können, wie die App verwendet wird, und Verbesserungen priorisieren können.",
|
||||
"maxConcurrentDownloads": "Maximale Anzahl aktiver Downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximale Anzahl gleichzeitiger Downloads",
|
||||
"none": "Keine",
|
||||
"oneClickDownload": "Ein-Klick-Download",
|
||||
"oneClickDownloadDescription": "Ein-Klick-Download mit Standardeinstellungen aktivieren",
|
||||
"oneClickDownloadType": "Standard-Download-Typ",
|
||||
"oneClickDownloadTypeDescription": "Wählen Sie den Standard-Download-Typ für Ein-Klick-Downloads. Die Qualität verwendet die unten stehende Voreinstellung.",
|
||||
"oneClickQuality": "Bevorzugte Qualität",
|
||||
"oneClickQualityDescription": "Wählen Sie die Qualitätsvoreinstellung für Ein-Klick-Downloads",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Automatisch",
|
||||
"bad": "Schlecht",
|
||||
"best": "Am besten",
|
||||
"good": "Gut",
|
||||
"normal": "Normal",
|
||||
"worst": "Am schlechtesten"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Proxy-Server für Netzwerkanfragen",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Konfigurationsdatei auswählen",
|
||||
"selectPath": "Auswählen",
|
||||
"showMoreFormats": "Mehr Formatoptionen anzeigen",
|
||||
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.",
|
||||
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Design",
|
||||
"themeDescription": "Wählen Sie ein helles, dunkles oder System-Design für VidBee",
|
||||
"title": "Einstellungen",
|
||||
"tray": {
|
||||
"quit": "Beenden",
|
||||
"showHome": "Startseite anzeigen"
|
||||
},
|
||||
"video": "Video-Einstellungen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Abonnements",
|
||||
"subtitle": "{{count}} Abonnement{{count, plural, one {} other {e}}}",
|
||||
"description": "Überwachen Sie RSS-Feeds automatisch und fügen Sie neue Downloads zur Warteschlange hinzu, ohne manuelle Arbeit.",
|
||||
"defaults": {
|
||||
"title": "Automatisierungs-Standardeinstellungen",
|
||||
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
|
||||
"downloadDirectory": "Download-Verzeichnis",
|
||||
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
|
||||
"checkInterval": "Prüfintervall (Stunden)",
|
||||
"onlyLatest": "Nur das neueste Video herunterladen",
|
||||
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
|
||||
},
|
||||
"add": {
|
||||
"title": "RSS hinzufügen",
|
||||
"description": "Fügen Sie einen RSS-Feed-Link ein. VidBee erkennt den Feed automatisch."
|
||||
},
|
||||
"fields": {
|
||||
"url": "Feed-URL",
|
||||
"keywords": "Schlüsselwortfilter (durch Komma getrennt)",
|
||||
"tags": "Automatische Tags",
|
||||
"customDirectory": "Benutzerdefiniertes Verzeichnis",
|
||||
"namingTemplate": "Benutzerdefinierte Dateinamen-Vorlage (nur Datei)",
|
||||
"onlyLatest": "Nur das neueste Video herunterladen",
|
||||
"onlyLatestDescription": "Backlog-Elemente ignorieren und nur den neuesten Upload aus diesem Feed abrufen.",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"onlyLatestShort": "Nur neueste"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Hinzufügen",
|
||||
"refresh": "Aktualisieren",
|
||||
"edit": "Bearbeiten",
|
||||
"remove": "Entfernen",
|
||||
"save": "Änderungen speichern",
|
||||
"selectDirectory": "Durchsuchen",
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren"
|
||||
},
|
||||
"items": {
|
||||
"title": "Neueste Uploads ({{count}})",
|
||||
"count": "{{count}} Elemente",
|
||||
"empty": "Keine kürzlichen Feed-Elemente gefunden.",
|
||||
"status": {
|
||||
"queued": "In Warteschlange",
|
||||
"notQueued": "Nicht in Warteschlange",
|
||||
"pending": "Ausstehend",
|
||||
"downloading": "Wird heruntergeladen",
|
||||
"processing": "Wird verarbeitet",
|
||||
"completed": "Abgeschlossen",
|
||||
"error": "Fehlgeschlagen",
|
||||
"cancelled": "Abgebrochen"
|
||||
},
|
||||
"fromChannel": "Von {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "Download-Status: {{status}}",
|
||||
"downloadPending": "Warten auf Download-Details...",
|
||||
"notQueued": "Noch nicht in der Download-Warteschlange"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Im Browser öffnen",
|
||||
"queue": "Zur Download-Warteschlange hinzufügen"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "Abonnement",
|
||||
"unknown": "Unbekanntes Abonnement",
|
||||
"noThumbnail": "Kein Vorschaubild"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "Verzeichnisauswahl konnte nicht geöffnet werden.",
|
||||
"missingUrl": "Bitte fügen Sie zuerst einen Kanal-Link ein.",
|
||||
"created": "Abonnement hinzugefügt",
|
||||
"createError": "Abonnement konnte nicht hinzugefügt werden.",
|
||||
"refreshStarted": "Aktualisierung gestartet",
|
||||
"removed": "Abonnement entfernt",
|
||||
"updated": "Abonnement aktualisiert",
|
||||
"itemQueued": "Zur Download-Warteschlange hinzugefügt",
|
||||
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
|
||||
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
|
||||
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
|
||||
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden."
|
||||
},
|
||||
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
|
||||
"detecting": "Feed wird erkannt...",
|
||||
"latestVideo": "Neuestes Video: {{title}}",
|
||||
"lastChecked": "Zuletzt geprüft: {{time}}",
|
||||
"never": "Nie",
|
||||
"empty": "Noch keine Abonnements. Fügen Sie Ihre Lieblingskanäle hinzu, um mit dem automatischen Download zu beginnen.",
|
||||
"edit": {
|
||||
"title": "{{name}} bearbeiten",
|
||||
"description": "Passen Sie Filter, Tags und Überschreibungen für diesen Feed an."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"up-to-date": "Aktuell",
|
||||
"checking": "Wird geprüft",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"idle": "Leerlauf",
|
||||
"tooltip": {
|
||||
"updatedAt": "Aktualisiert: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "Automatische Abonnements mit RSSHub",
|
||||
"description": "Kombinieren Sie VidBee mit RSSHub, um automatische Abonnements und Downloads von verschiedenen Plattformen zu ermöglichen. Nach der Einrichtung läuft VidBee im Hintergrund und lädt automatisch die neuesten Videos und Inhalte herunter.",
|
||||
"learnMore": "Mehr über RSSHub erfahren",
|
||||
"openDocs": "RSSHub-Dokumentation öffnen",
|
||||
"hint": "Keine RSS-Feed-URL? Verwenden Sie RSSHub, um RSS-Feeds für YouTube, Twitter und Tausende anderer Plattformen zu generieren."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Unterstützt {{sites}} und mehr.",
|
||||
"moreDescription": "Die vollständige yt-dlp-Liste wird ständig von der Community aktualisiert.",
|
||||
"moreTitle": "Benötigen Sie eine andere Website?",
|
||||
"openFullList": "Vollständige Liste der unterstützten Websites öffnen",
|
||||
"pageDescription": "VidBee verwendet yt-dlp unter der Haube, um Hunderte von Quellen zu erreichen.",
|
||||
"pageIntro": "Hier sind die gängigen Dienste, von denen die meisten Menschen herunterladen.",
|
||||
"pageTitle": "Unterstützte Websites",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Alben unabhängiger Künstler und Community-Veröffentlichungen.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Globale Nachrichten, Sport und Unterhaltungsclips.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Videos aus Feed, Watch und Reels von öffentlichen Seiten.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Inhalte aus Feed, Stories, Reels und Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Live-Streams und Wiederholungen von Creators auf der Kick-Plattform.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Professionelle Vorträge, Webinare und Lernvideos.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ-Mixe, Radiosendungen und lange Audioformate.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Japanische Animation, Musik und Live-Übertragungsarchiv.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Ideen-Pins, How-to-Reels und Lifestyle-Inspirationsvideos.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Eingebettete Clips und gehostete Videos aus Communities.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Musiktitel, Playlists und DJ-Sets.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Kurze mobile Videos, Effekte und Live-Streams.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Kreative kurze Medien und Fan-Bearbeitungen.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Gaming-, Musik- und IRL-Live-Streams und VODs.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Timeline-Posts, Spaces-Aufnahmen und Übertragungen.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hochwertiges Video-Hosting für Creators und Unternehmen.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Lange Videos und Livestreams von Creators weltweit.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Offizielle Musikvideos, Alben und Live-Auftritte.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Hauptplattformen",
|
||||
"viewAll": "Alle unterstützten Websites anzeigen"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Check updates",
|
||||
"download": "Download",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"goToDownload": "Go to download page",
|
||||
"openRepo": "Open GitHub repository",
|
||||
"view": "View",
|
||||
"visit": "Visit"
|
||||
@@ -13,19 +15,30 @@
|
||||
"autoUpdateTitle": "Auto updates",
|
||||
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
||||
"betaProgramTitle": "Preview channel",
|
||||
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
|
||||
"description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Follow @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
|
||||
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
|
||||
"followAuthorTitle": "Follow the Developer",
|
||||
"here": "here",
|
||||
"homepage": "Homepage",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Looking for updates...",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"noUpdatesAvailable": "You're using the latest version",
|
||||
"updateError": "Failed to check for updates: {{error}}",
|
||||
"downloadUpdate": "Download and install update {{version}}?",
|
||||
"downloadStarted": "Download started...",
|
||||
"downloadError": "Failed to download update",
|
||||
"downloadStarted": "Download started...",
|
||||
"downloadUpdate": "Download and install update {{version}}?",
|
||||
"manualDownloadAction": "Download now",
|
||||
"noUpdatesAvailable": "You're using the latest version",
|
||||
"restartToUpdate": "Restart now to install update?",
|
||||
"restartNowAction": "Restart now",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"updateAvailableMessage": "A new version {{version}} is available. Please download it from the official website.",
|
||||
"updateDownloaded": "Update downloaded, restart to install",
|
||||
"restartToUpdate": "Restart now to install update?"
|
||||
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
|
||||
"updateError": "Failed to check for updates: {{error}}",
|
||||
"unknownErrorFallback": "Unknown error"
|
||||
},
|
||||
"preferencesDescription": "Tune update settings without leaving this page.",
|
||||
"preferencesTitle": "Quick Toggles",
|
||||
@@ -38,6 +51,10 @@
|
||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||
"feedback": "Feedback & issues",
|
||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
||||
"githubIssues": "GitHub Issues",
|
||||
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||
"discord": "Discord Community",
|
||||
"discordDescription": "Join our Discord community for discussions and support.",
|
||||
"license": "License",
|
||||
"licenseDescription": "Review the open-source license terms.",
|
||||
"website": "Official website",
|
||||
@@ -45,25 +62,25 @@
|
||||
},
|
||||
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
|
||||
"resourcesTitle": "Resources",
|
||||
"shareActions": {
|
||||
"copy": "Copy link",
|
||||
"facebook": "Share on Facebook",
|
||||
"twitter": "Share on X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Share VidBee with your community in one click.",
|
||||
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
|
||||
"shareTitle": "Spread the word",
|
||||
"shareDescription": "Share VidBee with your community in one click.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Follow @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
|
||||
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
|
||||
"followAuthorTitle": "Follow the Developer",
|
||||
"shareActions": {
|
||||
"twitter": "Share on X (Twitter)",
|
||||
"facebook": "Share on Facebook",
|
||||
"copy": "Copy link"
|
||||
},
|
||||
"sourceCode": "Source Code is available",
|
||||
"tagline": "An AI-friendly download helper for every creator",
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Latest: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "New version available",
|
||||
"uptodate": "You're up to date",
|
||||
"error": "Unable to fetch the latest version"
|
||||
},
|
||||
"downloadingUpdate": "Downloading update"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Close app when download finishes",
|
||||
@@ -97,7 +114,7 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"download": {
|
||||
"active": "active",
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
"audio": "Audio",
|
||||
"back": "Back",
|
||||
@@ -110,6 +127,10 @@
|
||||
"downloadBtn": "Download",
|
||||
"downloadPending": "Pending",
|
||||
"downloadQueue": "Download Queue",
|
||||
"customDownloadFolder": "Custom download folder",
|
||||
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
|
||||
"autoFolderHint": "Automatic folders are created from metadata.",
|
||||
"useAutoFolder": "Use automatic folder",
|
||||
"downloadVideo": "Download Video",
|
||||
"downloading": "Downloading...",
|
||||
"enterUrl": "Enter Video URL",
|
||||
@@ -127,8 +148,10 @@
|
||||
"noAudio": "No Audio",
|
||||
"noHistory": "No download history",
|
||||
"noItems": "No items found",
|
||||
"goToSettings": "Go to Settings",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
|
||||
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
|
||||
"oneClickDownloadNow": "Download Now",
|
||||
"oneClickDownloadStarted": "Download started with default settings",
|
||||
"paste": "Paste",
|
||||
@@ -137,8 +160,12 @@
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectDownloadType": "Select download type",
|
||||
"selectFormat": "Select Format",
|
||||
"startDownload": "Start Download",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
"singleVideo": "Single Video",
|
||||
"speed": "Speed",
|
||||
@@ -149,7 +176,33 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Video Information",
|
||||
"videoInfoUpdated": "Video information updated"
|
||||
"videoInfoUpdated": "Video information updated",
|
||||
"metadata": {
|
||||
"source": "Source",
|
||||
"playlist": "Playlist",
|
||||
"format": "Format",
|
||||
"quality": "Quality",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Saved file",
|
||||
"url": "Source URL",
|
||||
"description": "Description",
|
||||
"views": "Views",
|
||||
"tags": "Tags",
|
||||
"downloadPath": "Download path",
|
||||
"createdAt": "Created at",
|
||||
"startedAt": "Started at",
|
||||
"completedAt": "Completed at",
|
||||
"speed": "Speed",
|
||||
"fileSize": "File size",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Video codec",
|
||||
"audioCodec": "Audio codec",
|
||||
"formatNote": "Format note",
|
||||
"protocol": "Protocol",
|
||||
"subscription": "Subscription"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Click to copy details",
|
||||
@@ -166,10 +219,23 @@
|
||||
"clearCancelled": "Clear Cancelled",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"clearErrors": "Clear Errors",
|
||||
"clearAll": "Clear All History",
|
||||
"clearAllAction": "Clear History",
|
||||
"clearSelection": "Clear Selection",
|
||||
"confirmClearAllTitle": "Clear all history?",
|
||||
"confirmClearAllDescription": "Remove {{count}} items from your history. Files stay on disk.",
|
||||
"confirmDeleteSelectedTitle": "Remove selected items?",
|
||||
"confirmDeleteSelectedDescription": "Remove {{count}} items from your history. Files stay on disk.",
|
||||
"alsoDeleteFiles": "Also delete files",
|
||||
"confirmDeletePlaylistTitle": "Remove playlist history?",
|
||||
"confirmDeletePlaylistDescription": "Remove {{count}} items from {{title}} and delete their files.",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyUrl": "Copy URL",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"date": "Date",
|
||||
"deletePlaylist": "Remove Playlist",
|
||||
"deleteSelected": "Remove Selected",
|
||||
"description": "View and manage your download history",
|
||||
"doneSelecting": "Done",
|
||||
"duration": "Duration",
|
||||
"fileSize": "File Size",
|
||||
"filters": {
|
||||
@@ -180,12 +246,19 @@
|
||||
},
|
||||
"noHistory": "No download history yet",
|
||||
"noHistoryDescription": "Your completed downloads will appear here",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"openFile": "Open File",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"openFileLocation": "Open File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeAction": "Remove",
|
||||
"removeItem": "Remove Item",
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"selectVisible": "Select visible",
|
||||
"selectItem": "Select item",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectionSummary": "{{selected}} of {{total}} visible selected",
|
||||
"stats": {
|
||||
"cancelled": "Cancelled",
|
||||
"completed": "Completed",
|
||||
@@ -203,24 +276,35 @@
|
||||
"about": "About",
|
||||
"download": "Download",
|
||||
"playlist": "Download Playlist",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Subscriptions",
|
||||
"preferences": "Preferences",
|
||||
"supportedSites": "Supported Sites",
|
||||
"theme": "Theme:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"videoCopied": "Video copied to clipboard",
|
||||
"downloadCompleted": "Download completed",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"historyCleared": "History cleared",
|
||||
"historyClearFailed": "Failed to clear history",
|
||||
"itemRemoved": "Item removed",
|
||||
"itemsRemoved": "Removed {{count}} items",
|
||||
"itemsRemoveFailed": "Failed to remove selected items",
|
||||
"openFileFailed": "Failed to open file",
|
||||
"openFolderFailed": "Failed to open folder",
|
||||
"playlistHistoryRemoved": "Playlist removed and files deleted",
|
||||
"playlistHistoryRemoveFailed": "Failed to remove playlist history",
|
||||
"removeFailed": "Failed to remove item",
|
||||
"settingsSaved": "Settings saved",
|
||||
"urlCopied": "URL copied to clipboard"
|
||||
"urlCopied": "URL copied to clipboard",
|
||||
"videoCopied": "Video copied to clipboard"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"collapsedProgress": "Downloading playlist: {{completed}} / {{total}} completed",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -235,12 +319,31 @@
|
||||
"filenameFormat": "Filename format for playlists",
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupCollapse": "Collapse",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupExpand": "Expand",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"linkLabel": "Playlist URL",
|
||||
"noEntries": "No videos were found in this playlist",
|
||||
"noEntriesInRange": "No videos in the selected range",
|
||||
"noRangeSelected": "No end set - full playlist selected",
|
||||
"playlistUrlDescription": "Download all videos from a playlist in bulk",
|
||||
"positionLabel": "Item {{index}} of {{total}}",
|
||||
"previewButton": "Preview playlist",
|
||||
"previewFailed": "Failed to preview playlist",
|
||||
"previewSummary": "Preview playlist items before downloading.",
|
||||
"previewRequired": "Preview the playlist before downloading.",
|
||||
"range": "Range (Optional)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}} selected",
|
||||
"downloadCurrentRange": "Download Selected",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist"
|
||||
"title": "Download Playlist",
|
||||
"totalVideos": "Total videos: {{count}}",
|
||||
"untitled": "Untitled playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "About",
|
||||
@@ -248,23 +351,51 @@
|
||||
"app": "App Settings",
|
||||
"audio": "Audio Preferences",
|
||||
"browserForCookies": "Select browser to use cookies from",
|
||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
|
||||
"cookiesFile": "Cookies file",
|
||||
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
||||
"clearCookiesFile": "Clear",
|
||||
"cookiesHelpTitle": "Using cookies",
|
||||
"cookiesHelpBrowser": "Pick your browser above to reuse its signed-in session automatically.",
|
||||
"cookiesHelpFile": "Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.",
|
||||
"cookiesHelpFaq": "Open yt-dlp cookies FAQ",
|
||||
"openLinkError": "Failed to open link",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"chromium": "Chromium",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Use configuration file",
|
||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||
"clearConfigFile": "Clear",
|
||||
"dark": "Dark",
|
||||
"description": "Configure your download preferences and application settings",
|
||||
"directorySelectError": "Failed to select directory",
|
||||
"downloadPath": "Download location",
|
||||
"downloadPathDescription": "Choose where to save downloaded files",
|
||||
"fileSelectError": "Failed to select file",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"languageOptions": {
|
||||
"chinese": "Chinese (Simplified)",
|
||||
"english": "English"
|
||||
},
|
||||
"light": "Light",
|
||||
"hideDockIcon": "Hide Dock icon",
|
||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||
"launchAtLogin": "Launch at startup",
|
||||
"launchAtLoginDescription": "Open VidBee automatically after you sign in to your computer.",
|
||||
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
|
||||
"enableAnalytics": "Help improve VidBee",
|
||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Enable one-click download with default settings",
|
||||
"oneClickDownloadType": "Default download type",
|
||||
"oneClickDownloadTypeDescription": "Choose the default download type for one-click downloads. Quality uses the preset below.",
|
||||
"oneClickQuality": "Preferred quality",
|
||||
"oneClickQualityDescription": "Select the quality preset used for one-click downloads",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Bad",
|
||||
@@ -274,11 +405,19 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Proxy server for network requests",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Select config file",
|
||||
"selectPath": "Select",
|
||||
"showMoreFormats": "Show more format options",
|
||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
||||
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||
"title": "Settings",
|
||||
"tray": {
|
||||
"quit": "Quit",
|
||||
@@ -286,6 +425,120 @@
|
||||
},
|
||||
"video": "Video Preferences"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Subscriptions",
|
||||
"subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}",
|
||||
"description": "Automatically monitor RSS feeds and queue new downloads without manual work.",
|
||||
"defaults": {
|
||||
"title": "Automation defaults",
|
||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||
"downloadDirectory": "Download directory",
|
||||
"filenameTemplate": "Filename template",
|
||||
"checkInterval": "Check interval (hours)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||
},
|
||||
"add": {
|
||||
"title": "Add RSS",
|
||||
"description": "Paste an RSS feed link. VidBee will detect the feed automatically."
|
||||
},
|
||||
"fields": {
|
||||
"url": "Feed URL",
|
||||
"keywords": "Keyword filter (comma separated)",
|
||||
"tags": "Auto tags",
|
||||
"customDirectory": "Custom directory",
|
||||
"namingTemplate": "Custom filename template",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"onlyLatestShort": "Only latest"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"save": "Save changes",
|
||||
"selectDirectory": "Browse",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"items": {
|
||||
"title": "Latest uploads ({{count}})",
|
||||
"count": "{{count}} items",
|
||||
"empty": "No recent feed items found.",
|
||||
"status": {
|
||||
"queued": "Queued",
|
||||
"notQueued": "Not queued",
|
||||
"pending": "Pending",
|
||||
"downloading": "Downloading",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"error": "Failed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"fromChannel": "From {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "Download status: {{status}}",
|
||||
"downloadPending": "Waiting for download details...",
|
||||
"notQueued": "Not in the download queue yet"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Open in browser",
|
||||
"queue": "Add to download queue"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "Subscription",
|
||||
"unknown": "Unknown subscription",
|
||||
"noThumbnail": "No thumbnail"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "Failed to open the directory picker.",
|
||||
"missingUrl": "Please paste a channel link first.",
|
||||
"created": "Subscription added",
|
||||
"createError": "Failed to add subscription.",
|
||||
"refreshStarted": "Refresh started",
|
||||
"removed": "Subscription removed",
|
||||
"updated": "Subscription updated",
|
||||
"itemQueued": "Added to download queue",
|
||||
"itemAlreadyQueued": "This video is already queued",
|
||||
"queueError": "Failed to add to download queue.",
|
||||
"openLinkError": "Failed to open the video link.",
|
||||
"resolveError": "Failed to resolve RSS feed URL."
|
||||
},
|
||||
"detectedFeed": "Detected {{platform}} feed -> {{feed}}",
|
||||
"detecting": "Detecting feed...",
|
||||
"latestVideo": "Latest video: {{title}}",
|
||||
"lastChecked": "Last checked: {{time}}",
|
||||
"never": "Never",
|
||||
"empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.",
|
||||
"edit": {
|
||||
"title": "Edit {{name}}",
|
||||
"description": "Tweak filters, tags, and overrides for this feed."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"up-to-date": "Up to date",
|
||||
"checking": "Checking",
|
||||
"failed": "Failed",
|
||||
"idle": "Idle",
|
||||
"tooltip": {
|
||||
"updatedAt": "Updated: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "Automated Subscriptions with RSSHub",
|
||||
"description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.",
|
||||
"learnMore": "Learn more about RSSHub",
|
||||
"openDocs": "Open RSSHub Documentation",
|
||||
"hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supports {{sites}} and more.",
|
||||
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
|
||||
@@ -294,6 +547,80 @@
|
||||
"pageDescription": "VidBee uses yt-dlp under the hood to reach hundreds of sources.",
|
||||
"pageIntro": "Here are the mainstream services people download from most frequently.",
|
||||
"pageTitle": "Supported Sites",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Independent artist albums and community releases.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Global news, sports, and entertainment clips.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Feed, Watch, and Reels videos from public pages.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Feed, Stories, Reels, and Highlights content.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Creator live streams and replays on the Kick platform.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Professional talks, webinars, and learning videos.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ mixes, radio shows, and long-form audio.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Japanese animation, music, and live broadcast archive.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Idea pins, how-to reels, and lifestyle inspiration videos.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Embedded clips and hosted videos from communities.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Music tracks, playlists, and DJ sets.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Short-form mobile videos, effects, and live streams.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Creative short-form media and fan edits.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Gaming, music, and IRL live streams and VODs.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Timeline posts, Spaces recordings, and broadcasts.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "High-quality creator and business video hosting.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Long-form and livestream video from creators worldwide.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Official music videos, albums, and live performances.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Main platforms",
|
||||
"viewAll": "View all supported sites"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user