Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f646f287e6 | ||
|
|
603a4f051d | ||
|
|
e2bfa334c0 | ||
|
|
e8769fefb0 | ||
|
|
b88411d229 | ||
|
|
b6c67b23f6 | ||
|
|
cab86fe71c | ||
|
|
129899ed61 | ||
|
|
1f821b67dd | ||
|
|
6f1c6ce59e | ||
|
|
e5d36da904 | ||
|
|
9c3fd2c26f | ||
|
|
894eb9774b | ||
|
|
73af211bef | ||
|
|
4b561cef38 | ||
|
|
761adf2476 | ||
|
|
182a1b9c1e | ||
|
|
59aee07913 | ||
|
|
3d39c9751f |
60
.github/workflows/build.yml
vendored
@@ -33,16 +33,16 @@ jobs:
|
|||||||
ytdlp_output: 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_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_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
|
||||||
ffmpeg_output: ffmpeg.exe
|
ffprobe_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe
|
||||||
- platform: macos
|
- platform: macos
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
build_script: pnpm run build:mac
|
build_script: pnpm run build:mac
|
||||||
ytdlp_asset: yt-dlp_macos
|
ytdlp_asset: yt-dlp_macos
|
||||||
ytdlp_output: 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_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip
|
||||||
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
|
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip
|
||||||
ffmpeg_inner_path: ffmpeg/ffmpeg
|
ffmpeg_inner_path: ffmpeg/ffmpeg
|
||||||
ffmpeg_output: ffmpeg_macos
|
ffprobe_inner_path: ffmpeg/ffprobe
|
||||||
- platform: linux
|
- platform: linux
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
build_script: pnpm run build:linux
|
build_script: pnpm run build:linux
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
ytdlp_output: yt-dlp_linux
|
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_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_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
|
||||||
ffmpeg_output: ffmpeg_linux
|
ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -77,8 +77,11 @@ jobs:
|
|||||||
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
|
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
|
||||||
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
|
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
|
||||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
$ffprobeSource = Join-Path 'ffmpeg' '${{ matrix.ffprobe_inner_path }}'
|
||||||
Copy-Item -Path $source -Destination $destination -Force
|
$destinationDir = Join-Path 'resources' 'ffmpeg'
|
||||||
|
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||||
|
Copy-Item -Path $source -Destination (Join-Path $destinationDir 'ffmpeg.exe') -Force
|
||||||
|
Copy-Item -Path $ffprobeSource -Destination (Join-Path $destinationDir 'ffprobe.exe') -Force
|
||||||
Remove-Item ffmpeg.zip -Force
|
Remove-Item ffmpeg.zip -Force
|
||||||
Remove-Item ffmpeg -Recurse -Force
|
Remove-Item ffmpeg -Recurse -Force
|
||||||
|
|
||||||
@@ -86,7 +89,8 @@ jobs:
|
|||||||
if: matrix.platform == 'macos'
|
if: matrix.platform == 'macos'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
FFMPEG_OUTPUT: ffmpeg
|
||||||
|
FFPROBE_OUTPUT: ffprobe
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||||
@@ -97,6 +101,8 @@ jobs:
|
|||||||
|
|
||||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||||
|
arm_probe="ffmpeg-arm/${{ matrix.ffprobe_inner_path }}"
|
||||||
|
x86_probe="ffmpeg-x86/${{ matrix.ffprobe_inner_path }}"
|
||||||
|
|
||||||
if [[ ! -f "$arm_bin" ]]; then
|
if [[ ! -f "$arm_bin" ]]; then
|
||||||
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
|
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
|
||||||
@@ -104,6 +110,12 @@ jobs:
|
|||||||
if [[ ! -f "$x86_bin" ]]; then
|
if [[ ! -f "$x86_bin" ]]; then
|
||||||
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
|
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
|
||||||
fi
|
fi
|
||||||
|
if [[ ! -f "$arm_probe" ]]; then
|
||||||
|
arm_probe="$(find ffmpeg-arm -type f -name ffprobe -print -quit)"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$x86_probe" ]]; then
|
||||||
|
x86_probe="$(find ffmpeg-x86 -type f -name ffprobe -print -quit)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$arm_bin" ]]; then
|
if [[ ! -f "$arm_bin" ]]; then
|
||||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||||
@@ -113,9 +125,19 @@ jobs:
|
|||||||
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
|
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ ! -f "$arm_probe" ]]; then
|
||||||
|
echo "::error::Missing arm64 ffprobe binary at $arm_probe"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$x86_probe" ]]; then
|
||||||
|
echo "::error::Missing x86_64 ffprobe binary at $x86_probe"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
|
mkdir -p resources/ffmpeg
|
||||||
chmod +x "resources/$FFMPEG_OUTPUT"
|
lipo -create "$arm_bin" "$x86_bin" -output "resources/ffmpeg/$FFMPEG_OUTPUT"
|
||||||
|
lipo -create "$arm_probe" "$x86_probe" -output "resources/ffmpeg/$FFPROBE_OUTPUT"
|
||||||
|
chmod +x "resources/ffmpeg/$FFMPEG_OUTPUT" "resources/ffmpeg/$FFPROBE_OUTPUT"
|
||||||
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
|
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
|
||||||
|
|
||||||
- name: Download ffmpeg binary (Linux)
|
- name: Download ffmpeg binary (Linux)
|
||||||
@@ -130,8 +152,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
mkdir ffmpeg
|
mkdir ffmpeg
|
||||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
mkdir -p resources/ffmpeg
|
||||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/ffmpeg/ffmpeg"
|
||||||
|
cp "ffmpeg/${{ matrix.ffprobe_inner_path }}" "resources/ffmpeg/ffprobe"
|
||||||
|
chmod +x "resources/ffmpeg/ffmpeg" "resources/ffmpeg/ffprobe"
|
||||||
rm -rf ffmpeg.tar.xz ffmpeg
|
rm -rf ffmpeg.tar.xz ffmpeg
|
||||||
|
|
||||||
- name: Download yt-dlp binary
|
- name: Download yt-dlp binary
|
||||||
@@ -230,5 +254,15 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: dist-${{ matrix.os }}
|
name: dist-${{ matrix.os }}
|
||||||
path: dist/
|
path: |
|
||||||
|
dist/*.exe
|
||||||
|
dist/*.zip
|
||||||
|
dist/*.dmg
|
||||||
|
dist/*.AppImage
|
||||||
|
dist/*.snap
|
||||||
|
dist/*.deb
|
||||||
|
dist/*.rpm
|
||||||
|
dist/*.tar.gz
|
||||||
|
dist/*.yml
|
||||||
|
dist/*.blockmap
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
@@ -7,3 +7,5 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
upload_artifacts: true
|
||||||
|
|||||||
4
.github/workflows/translator.yaml
vendored
@@ -8,10 +8,6 @@ on:
|
|||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
discussion_comment:
|
discussion_comment:
|
||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
translate:
|
translate:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ src/
|
|||||||
- Build production bundles with `pnpm build`.
|
- Build production bundles with `pnpm build`.
|
||||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
- 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.
|
- 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.
|
- Bundle platform binaries of `yt-dlp` and `ffmpeg/ffprobe` under `resources/ffmpeg/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
|
||||||
|
|
||||||
## Working on Changes
|
## Working on Changes
|
||||||
- Keep each pull request focused on a single problem or feature.
|
- Keep each pull request focused on a single problem or feature.
|
||||||
|
|||||||
68
README.md
@@ -26,48 +26,7 @@ VidBee is a modern, open-source video downloader that lets you download videos a
|
|||||||
|
|
||||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
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:
|
[📥 Download VidBee](https://vidbee.org/download/) | [📚 Documentation](https://docs.vidbee.org)
|
||||||
|
|
||||||
<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]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
@@ -102,29 +61,14 @@ Automatically subscribe to RSS feeds and auto-download new videos in the backgro
|
|||||||
|
|
||||||
## 🌐 Supported Sites
|
## 🌐 Supported Sites
|
||||||
|
|
||||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
VidBee supports 1000+ video and audio platforms through yt-dlp. For the complete list of supported sites, visit [https://vidbee.org/supported-sites/](https://vidbee.org/supported-sites/)
|
||||||
|
|
||||||
| 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 | |
|
|
||||||
|
|
||||||
> **💡 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
|
||||||
|
|
||||||
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
|
You are welcome to join the open source community to build together. For more details, check out:
|
||||||
|
|
||||||
|
- [Contributing Guide](./CONTRIBUTING.md)
|
||||||
|
- [DeepWiki Documentation](https://deepwiki.com/nexmoe/VidBee)
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,13 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
"**/*.jsx",
|
"**/*.jsx",
|
||||||
"**/*.json",
|
"**/*.json",
|
||||||
|
"!docs",
|
||||||
"!monkey/dist",
|
"!monkey/dist",
|
||||||
"!dist",
|
"!dist",
|
||||||
"!out",
|
"!out",
|
||||||
"!build"
|
"!build"
|
||||||
],
|
],
|
||||||
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
|
"experimentalScannerIgnores": ["docs/**", "monkey/dist/**", "dist/**", "out/**", "build/**"]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ const { execFileSync } = require('node:child_process')
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
|
||||||
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
|
const BINARIES = [
|
||||||
|
'yt-dlp_macos',
|
||||||
|
path.join('ffmpeg', 'ffmpeg'),
|
||||||
|
path.join('ffmpeg', 'ffprobe'),
|
||||||
|
'deno'
|
||||||
|
]
|
||||||
|
|
||||||
const findAppBundle = (appOutDir) => {
|
const findAppBundle = (appOutDir) => {
|
||||||
const entries = fs.readdirSync(appOutDir)
|
const entries = fs.readdirSync(appOutDir)
|
||||||
|
|||||||
26
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# deps
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# generated content
|
||||||
|
.source
|
||||||
|
|
||||||
|
# test & build
|
||||||
|
/coverage
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/build
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# others
|
||||||
|
.env*.local
|
||||||
|
.vercel
|
||||||
|
next-env.d.ts
|
||||||
45
docs/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# docs
|
||||||
|
|
||||||
|
This is a Next.js application generated with
|
||||||
|
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||||
|
|
||||||
|
Run development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000 with your browser to see the result.
|
||||||
|
|
||||||
|
## Explore
|
||||||
|
|
||||||
|
In the project, you can see:
|
||||||
|
|
||||||
|
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||||
|
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| ------------------------- | ------------------------------------------------------ |
|
||||||
|
| `app/(home)` | The route group for your landing page and other pages. |
|
||||||
|
| `app/docs` | The documentation layout and pages. |
|
||||||
|
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||||
|
|
||||||
|
### Fumadocs MDX
|
||||||
|
|
||||||
|
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||||
|
|
||||||
|
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js and Fumadocs, take a look at the following
|
||||||
|
resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||||
|
features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||||
41
docs/biome.config.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!node_modules",
|
||||||
|
"!.next",
|
||||||
|
"!dist",
|
||||||
|
"!build",
|
||||||
|
"!.source"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"next": "recommended",
|
||||||
|
"react": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
docs/content/cookies.mdx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Cookies
|
||||||
|
description: Signed-in downloads, restricted content, and cookie setup
|
||||||
|
---
|
||||||
|
|
||||||
|
Cookies reuse your browser's signed-in session so VidBee can download content that requires login or verification, such as subscriber-only items, age-gated pages, or private links.
|
||||||
|
|
||||||
|
## When you need cookies
|
||||||
|
|
||||||
|
- Content that requires an account to view or download.
|
||||||
|
- Age-gated or region-locked pages.
|
||||||
|
- Private links or lists visible only to signed-in users.
|
||||||
|
|
||||||
|
## Two ways to use cookies in VidBee
|
||||||
|
|
||||||
|
### Option 1: Read cookies from your browser
|
||||||
|
|
||||||
|
In **Settings**, select your browser. VidBee will try to detect the browser profile path automatically. You can also enter the profile path manually.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Supported browsers (platform dependent):**
|
||||||
|
|
||||||
|
- **Windows: Firefox only. Other browsers cannot be used for cookie reading.**
|
||||||
|
- macOS: All browsers supported.
|
||||||
|
- Linux: All browsers supported.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Open VidBee → Settings → Cookies.
|
||||||
|
2. Select a browser and confirm the profile path.
|
||||||
|
3. Go back and start the download again.
|
||||||
|
|
||||||
|
If detection fails or the path is invalid, manually choose the actual browser profile directory.
|
||||||
|
On Windows, if you are not using Firefox, switch to the cookies file method.
|
||||||
|
|
||||||
|
### Option 2: Import a cookies file
|
||||||
|
|
||||||
|
You can import a **Netscape-formatted** cookies file. This is useful when reading the browser profile is not possible.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Export a Netscape cookies file using a browser extension.
|
||||||
|
2. Open VidBee → Settings → Cookies file, and select the exported file.
|
||||||
|
3. Click Clear to disable it.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- Prefer browser-based cookies when possible, because it's easier to maintain.
|
||||||
|
- If browser reading fails, switch to a cookies file.
|
||||||
|
- Cookies files expire. Re-export when your account state changes.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Profile path is invalid**: Ensure the folder exists and points to the real browser profile directory.
|
||||||
|
- **Still asks for login**: Confirm your browser is signed in and re-save the setting.
|
||||||
|
- **Browser read failed**: Close the browser and retry, or switch to a cookies file.
|
||||||
|
|
||||||
|
## Privacy and security
|
||||||
|
|
||||||
|
Cookies are equivalent to your login session. Keep them private and never share or upload them. If you suspect leakage, sign out and change your password.
|
||||||
36
docs/content/faq.mdx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: FAQ
|
||||||
|
description: Common questions and troubleshooting for VidBee
|
||||||
|
---
|
||||||
|
|
||||||
|
## What if a download fails or shows an error?
|
||||||
|
|
||||||
|
- Confirm the link is valid and opens in your browser.
|
||||||
|
- Update VidBee to the latest version and retry.
|
||||||
|
- If the content requires login or age verification, configure cookies.
|
||||||
|
|
||||||
|
## Why does the same link sometimes work and sometimes fail?
|
||||||
|
|
||||||
|
Some sites frequently change page structure or limit request rates. Suggested steps:
|
||||||
|
|
||||||
|
- Make sure VidBee is up to date.
|
||||||
|
- Reduce the number of concurrent downloads.
|
||||||
|
- Use cookies to reuse your signed-in session when needed.
|
||||||
|
|
||||||
|
## Which sites are supported?
|
||||||
|
|
||||||
|
VidBee uses the yt-dlp extractor system and supports many sites. Try the download first; if it fails, submit feedback with the link and error details.
|
||||||
|
|
||||||
|
## Why is the download speed slow?
|
||||||
|
|
||||||
|
- Check your network and proxy settings.
|
||||||
|
- Avoid starting too many tasks at once.
|
||||||
|
- Some sites limit bandwidth on their side.
|
||||||
|
|
||||||
|
## macOS says “file is damaged”
|
||||||
|
|
||||||
|
Remove the quarantine attribute and retry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||||
|
```
|
||||||
20
docs/content/index.mdx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
description: VidBee desktop downloader documentation and FAQ
|
||||||
|
---
|
||||||
|
|
||||||
|
VidBee is a desktop downloader built with Electron and powered by yt-dlp. It provides a clean interface and queue management for downloading video and audio.
|
||||||
|
|
||||||
|
These docs focus on real-world usage and settings, especially signed-in downloads and cookie configuration.
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
- [vidbee:// Protocol](./protocol.mdx): Quick download using URL protocol.
|
||||||
|
- [Cookies](./cookies.mdx): Configure signed-in sessions and restricted content.
|
||||||
|
- [FAQ](./faq.mdx): Common questions and troubleshooting.
|
||||||
|
|
||||||
|
## Quick links
|
||||||
|
|
||||||
|
- [VidBee website](https://vidbee.org/)
|
||||||
|
- [Supported sites](https://vidbee.org/supported-sites/)
|
||||||
|
- [Features](https://vidbee.org/features/)
|
||||||
4
docs/content/meta.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "VidBee Docs",
|
||||||
|
"pages": ["index", "protocol", "cookies", "faq"]
|
||||||
|
}
|
||||||
149
docs/content/protocol.mdx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: vidbee:// Protocol
|
||||||
|
description: Quick download using vidbee:// URL protocol
|
||||||
|
---
|
||||||
|
|
||||||
|
VidBee registers a custom URL protocol (`vidbee://`) that allows you to trigger downloads directly from web browsers, browser extensions, or userscripts.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
The `vidbee://` protocol can be used to open VidBee and automatically start downloading videos.
|
||||||
|
|
||||||
|
### Protocol Format
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://download?url=<encoded-video-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url` (required): The video URL to download, must be URL-encoded
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
To download a YouTube video:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ">
|
||||||
|
Download with VidBee
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in JavaScript:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opening VidBee
|
||||||
|
|
||||||
|
To simply open the VidBee app without starting a download:
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Browser Extension
|
||||||
|
|
||||||
|
The VidBee browser extension uses this protocol to send the current tab's URL to the desktop app:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const currentUrl = window.location.href
|
||||||
|
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||||
|
window.location.href = deepLink
|
||||||
|
```
|
||||||
|
|
||||||
|
### Userscript Integration
|
||||||
|
|
||||||
|
The VidBee userscript adds quick download buttons to supported video sites:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Single click triggers download via protocol
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Pages
|
||||||
|
|
||||||
|
You can add direct download links to your web pages:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Simple link -->
|
||||||
|
<a href="vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo">
|
||||||
|
Download with VidBee
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Button with JavaScript -->
|
||||||
|
<button onclick="openInVidBee('https://example.com/video')">
|
||||||
|
Quick Download
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openInVidBee(url) {
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playlist Support
|
||||||
|
|
||||||
|
To download an entire playlist:
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://download?url=<encoded-playlist-url>&type=playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url` (required): The playlist URL, must be URL-encoded
|
||||||
|
- `type`: Set to `playlist` to download all videos in the playlist
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Protocol Registration**: VidBee registers as the handler for the `vidbee://` protocol during installation
|
||||||
|
2. **URL Parsing**: When a `vidbee://download?url=...` link is clicked, the OS launches VidBee
|
||||||
|
3. **Queue Processing**: VidBee extracts the video URL and adds it to the download queue
|
||||||
|
4. **Auto-start**: The download begins automatically if the app is configured for auto-download
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
The `vidbee://` protocol works across all major browsers:
|
||||||
|
- Chrome/Edge/Brave
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Only URLs starting with `vidbee://` will trigger the app
|
||||||
|
- The app validates the URL format before processing
|
||||||
|
- Malformed URLs are ignored with a warning in the logs
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Protocol Not Working
|
||||||
|
|
||||||
|
If clicking `vidbee://` links doesn't open VidBee:
|
||||||
|
|
||||||
|
1. **Check installation**: Ensure VidBee is properly installed
|
||||||
|
2. **Reinstall**: Try reinstalling VidBee to re-register the protocol
|
||||||
|
3. **OS permissions**: On macOS, check System Settings > Privacy & Security for any blocks
|
||||||
|
4. **Browser settings**: Some browsers may require you to allow the protocol on first use
|
||||||
|
|
||||||
|
### App Opens But Doesn't Download
|
||||||
|
|
||||||
|
If VidBee opens but the download doesn't start:
|
||||||
|
|
||||||
|
1. **Check URL encoding**: Ensure the video URL is properly encoded with `encodeURIComponent()`
|
||||||
|
2. **Check logs**: Open the app and check the developer console for errors
|
||||||
|
3. **Supported sites**: Verify the URL is from a [supported site](https://vidbee.org/supported-sites/)
|
||||||
63
docs/content/zh/cookies.mdx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Cookie 使用说明
|
||||||
|
description: 登录下载、受限内容与 Cookie 配置
|
||||||
|
---
|
||||||
|
|
||||||
|
Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或验证的内容,例如订阅内容、年龄限制或私密链接。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 需要账号登录才能观看或下载的内容。
|
||||||
|
- 年龄限制或地区限制页面。
|
||||||
|
- 仅对登录用户可见的私密链接或列表。
|
||||||
|
|
||||||
|
## VidBee 支持的两种方式
|
||||||
|
|
||||||
|
### 方式一:读取浏览器 Cookie
|
||||||
|
|
||||||
|
在 **设置** 中选择你的浏览器,VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**支持的浏览器(按平台差异显示):**
|
||||||
|
|
||||||
|
- **Windows:仅支持 Firefox。其他浏览器无法读取 Cookie。**
|
||||||
|
- macOS:全部支持。
|
||||||
|
- Linux:全部支持。
|
||||||
|
|
||||||
|
**使用步骤:**
|
||||||
|
|
||||||
|
1. 打开 VidBee → 设置 → Cookie。
|
||||||
|
2. 选择浏览器并确认配置文件路径。
|
||||||
|
3. 返回下载页面,重新开始下载任务。
|
||||||
|
|
||||||
|
如果识别失败或路径无效,请手动选择实际的浏览器配置文件目录。
|
||||||
|
在 Windows 上,如果不是 Firefox,请改用 cookies 文件方式。
|
||||||
|
|
||||||
|
### 方式二:导入 Cookies 文件
|
||||||
|
|
||||||
|
你也可以导入 **Netscape 格式** 的 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**使用步骤:**
|
||||||
|
|
||||||
|
1. 使用浏览器扩展导出 Netscape cookies 文件。
|
||||||
|
2. 打开 VidBee → 设置 → Cookies 文件,选择导出的文件。
|
||||||
|
3. 如需停用,可点击“清除”。
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
- 优先使用浏览器读取方式,维护成本更低。
|
||||||
|
- 如果浏览器读取失败,再切换到 cookies 文件方式。
|
||||||
|
- cookies 文件会过期,账号状态变化时需要重新导出。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- **提示配置文件路径无效**:请确认路径存在,并指向实际的浏览器配置文件目录。
|
||||||
|
- **下载仍提示需要登录**:确认当前浏览器已登录对应账号,并重新保存设置。
|
||||||
|
- **浏览器读取失败**:关闭浏览器后重试,或改用 cookies 文件方式。
|
||||||
|
|
||||||
|
## 隐私与安全
|
||||||
|
|
||||||
|
Cookie 等同于登录态,请妥善保管,不要共享或上传。若怀疑泄露,请及时在网站上退出登录并更新密码。
|
||||||
36
docs/content/zh/faq.mdx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: 常见问题 (FAQ)
|
||||||
|
description: VidBee 使用过程中的常见问题与建议
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载失败或报错怎么办?
|
||||||
|
|
||||||
|
- 先确认链接是否有效,并尝试在浏览器中打开。
|
||||||
|
- 升级 VidBee 到最新版本后重试。
|
||||||
|
- 如果是登录或年龄限制内容,请配置 Cookie。
|
||||||
|
|
||||||
|
## 为什么同一个链接有时可以、有时不行?
|
||||||
|
|
||||||
|
部分站点会频繁更新页面结构或限制请求频率。建议:
|
||||||
|
|
||||||
|
- 确保 VidBee 为最新版本。
|
||||||
|
- 适当降低同时下载任务数量。
|
||||||
|
- 必要时使用 Cookie 复用登录态。
|
||||||
|
|
||||||
|
## 支持哪些网站?
|
||||||
|
|
||||||
|
VidBee 基于 yt-dlp 解析器体系,覆盖大量站点。可以先尝试下载;若失败,请提交反馈并附上链接与错误提示。
|
||||||
|
|
||||||
|
## 下载速度慢怎么办?
|
||||||
|
|
||||||
|
- 检查本地网络与代理设置。
|
||||||
|
- 避免同时发起过多任务。
|
||||||
|
- 某些站点本身带宽较低,速度受限。
|
||||||
|
|
||||||
|
## macOS 提示“文件已损坏”
|
||||||
|
|
||||||
|
请执行以下命令移除隔离标记后重试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||||
|
```
|
||||||
20
docs/content/zh/index.mdx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: 简介
|
||||||
|
description: VidBee 桌面下载器使用说明与常见问题
|
||||||
|
---
|
||||||
|
|
||||||
|
VidBee 是一款基于 Electron 的桌面下载器,内置 yt-dlp 引擎,提供清爽的界面与队列管理能力,用于下载视频与音频内容。
|
||||||
|
|
||||||
|
这份文档聚焦 VidBee 的实际使用场景与设置说明,尤其是登录下载与 Cookie 相关配置。
|
||||||
|
|
||||||
|
## 从这里开始
|
||||||
|
|
||||||
|
- [vidbee:// 协议](./protocol.mdx):使用 URL 协议快速下载。
|
||||||
|
- [Cookie 使用](./cookies.mdx):登录态与限制内容的下载配置。
|
||||||
|
- [常见问题](./faq.mdx):常见问题与排查思路。
|
||||||
|
|
||||||
|
## 快捷链接
|
||||||
|
|
||||||
|
- [VidBee 官网](https://vidbee.org/)
|
||||||
|
- [支持站点](https://vidbee.org/supported-sites/)
|
||||||
|
- [功能介绍](https://vidbee.org/features/)
|
||||||
4
docs/content/zh/meta.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "VidBee 文档",
|
||||||
|
"pages": ["index", "protocol", "cookies", "faq"]
|
||||||
|
}
|
||||||
149
docs/content/zh/protocol.mdx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: vidbee:// 协议
|
||||||
|
description: 使用 vidbee:// URL 协议快速下载
|
||||||
|
---
|
||||||
|
|
||||||
|
VidBee 注册了自定义 URL 协议(`vidbee://`),允许您直接从网页浏览器、浏览器扩展或用户脚本触发下载。
|
||||||
|
|
||||||
|
## 基本用法
|
||||||
|
|
||||||
|
`vidbee://` 协议可用于打开 VidBee 并自动开始下载视频。
|
||||||
|
|
||||||
|
### 协议格式
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://download?url=<编码后的视频URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `url`(必需):要下载的视频 URL,必须经过 URL 编码
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
下载 YouTube 视频:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ">
|
||||||
|
使用 VidBee 下载
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 JavaScript:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
## 打开 VidBee
|
||||||
|
|
||||||
|
仅打开 VidBee 应用而不开始下载:
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 浏览器扩展
|
||||||
|
|
||||||
|
VidBee 浏览器扩展使用此协议将当前标签页的 URL 发送到桌面应用:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const currentUrl = window.location.href
|
||||||
|
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||||
|
window.location.href = deepLink
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户脚本集成
|
||||||
|
|
||||||
|
VidBee 用户脚本在支持的视频网站上添加快速下载按钮:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 单击通过协议触发下载
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网页集成
|
||||||
|
|
||||||
|
您可以在网页中添加直接下载链接:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 简单链接 -->
|
||||||
|
<a href="vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo">
|
||||||
|
使用 VidBee 下载
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 带 JavaScript 的按钮 -->
|
||||||
|
<button onclick="openInVidBee('https://example.com/video')">
|
||||||
|
快速下载
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openInVidBee(url) {
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 播放列表支持
|
||||||
|
|
||||||
|
下载整个播放列表:
|
||||||
|
|
||||||
|
```
|
||||||
|
vidbee://download?url=<编码后的播放列表URL>&type=playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `url`(必需):播放列表 URL,必须经过 URL 编码
|
||||||
|
- `type`:设置为 `playlist` 以下载播放列表中的所有视频
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
1. **协议注册**:VidBee 在安装过程中注册为 `vidbee://` 协议的处理程序
|
||||||
|
2. **URL 解析**:当点击 `vidbee://download?url=...` 链接时,操作系统会启动 VidBee
|
||||||
|
3. **队列处理**:VidBee 提取视频 URL 并将其添加到下载队列
|
||||||
|
4. **自动开始**:如果应用配置为自动下载,下载会自动开始
|
||||||
|
|
||||||
|
## 浏览器兼容性
|
||||||
|
|
||||||
|
`vidbee://` 协议适用于所有主流浏览器:
|
||||||
|
- Chrome/Edge/Brave
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
|
||||||
|
- 仅以 `vidbee://` 开头的 URL 会触发应用
|
||||||
|
- 应用在处理前会验证 URL 格式
|
||||||
|
- 格式错误的 URL 将被忽略,并在日志中显示警告
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 协议不工作
|
||||||
|
|
||||||
|
如果点击 `vidbee://` 链接无法打开 VidBee:
|
||||||
|
|
||||||
|
1. **检查安装**:确保 VidBee 已正确安装
|
||||||
|
2. **重新安装**:尝试重新安装 VidBee 以重新注册协议
|
||||||
|
3. **操作系统权限**:在 macOS 上,检查系统设置 > 隐私与安全性 是否有任何阻止
|
||||||
|
4. **浏览器设置**:某些浏览器可能需要您在首次使用时允许该协议
|
||||||
|
|
||||||
|
### 应用打开但未下载
|
||||||
|
|
||||||
|
如果 VidBee 打开但下载未开始:
|
||||||
|
|
||||||
|
1. **检查 URL 编码**:确保视频 URL 使用 `encodeURIComponent()` 正确编码
|
||||||
|
2. **检查日志**:打开应用并检查开发者控制台是否有错误
|
||||||
|
3. **支持的网站**:验证 URL 是否来自[支持的网站](https://vidbee.org/supported-sites/)
|
||||||
16
docs/next.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createMDX } from 'fumadocs-mdx/next';
|
||||||
|
|
||||||
|
const withMDX = createMDX();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const config = {
|
||||||
|
// Only use static export for production builds, not dev mode
|
||||||
|
output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
|
||||||
|
reactStrictMode: true,
|
||||||
|
// Use trailing slashes to avoid conflicts with route handlers that have file extensions
|
||||||
|
trailingSlash: true,
|
||||||
|
// Note: rewrites are not supported with static export
|
||||||
|
// The /llms.mdx route will be pre-rendered as static files
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withMDX(config);
|
||||||
36
docs/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build && node scripts/post-export.js",
|
||||||
|
"dev": "next dev",
|
||||||
|
"start": "next start",
|
||||||
|
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||||
|
"postinstall": "fumadocs-mdx",
|
||||||
|
"lint": "biome check --config-path biome.config.json",
|
||||||
|
"format": "biome format --write --config-path biome.config.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fumadocs-core": "16.4.7",
|
||||||
|
"fumadocs-mdx": "14.2.5",
|
||||||
|
"fumadocs-ui": "16.4.7",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"next": "16.1.1",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/node": "^25.0.5",
|
||||||
|
"@types/react": "^19.2.8",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@biomejs/biome": "^2.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
3989
docs/pnpm-lock.yaml
generated
Normal file
5
docs/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
101
docs/public/ICONS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# VidBee Documentation Icons
|
||||||
|
|
||||||
|
This directory contains the VidBee logo in various sizes for use in the documentation site.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
All icons are generated from the original VidBee application icon located at:
|
||||||
|
`/Users/air15/Documents/GitHub/VidBee/build/icon.png`
|
||||||
|
|
||||||
|
## Available Sizes
|
||||||
|
|
||||||
|
| Filename | Size | Use Case |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `icon.png` | 512×512 | Default/Original icon |
|
||||||
|
| `icon-16.png` | 16×16 | Browser favicon (small) |
|
||||||
|
| `icon-32.png` | 32×32 | Browser favicon (standard) |
|
||||||
|
| `icon-48.png` | 48×48 | Browser favicon (large) |
|
||||||
|
| `icon-64.png` | 64×64 | Small UI elements |
|
||||||
|
| `icon-128.png` | 128×128 | Medium UI elements |
|
||||||
|
| `icon-192.png` | 192×192 | PWA icon (Android) |
|
||||||
|
| `icon-256.png` | 256×256 | Large UI elements |
|
||||||
|
| `icon-512.png` | 512×512 | PWA splash screen, high-res displays |
|
||||||
|
| `apple-touch-icon.png` | 180×180 | iOS/macOS home screen icon |
|
||||||
|
| `favicon.png` | 32×32 | Standard favicon |
|
||||||
|
|
||||||
|
## Usage in Next.js
|
||||||
|
|
||||||
|
### In `app/layout.tsx` or `app/favicon.ico`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'VidBee Documentation',
|
||||||
|
description: 'Official VidBee documentation',
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/icon-16.png', sizes: '16x16', type: 'image/png' },
|
||||||
|
{ url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
{ url: '/icon-48.png', sizes: '48x48', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### For PWA (Progressive Web App):
|
||||||
|
|
||||||
|
Add to your `manifest.json` or `site.webmanifest`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regeneration
|
||||||
|
|
||||||
|
If you need to regenerate these icons from the source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs/public
|
||||||
|
SOURCE="/Users/air15/Documents/GitHub/VidBee/build/icon.png"
|
||||||
|
|
||||||
|
# Copy original
|
||||||
|
cp $SOURCE icon-original.png
|
||||||
|
|
||||||
|
# Generate sizes
|
||||||
|
sips -z 16 16 icon-original.png --out icon-16.png
|
||||||
|
sips -z 32 32 icon-original.png --out icon-32.png
|
||||||
|
sips -z 48 48 icon-original.png --out icon-48.png
|
||||||
|
sips -z 64 64 icon-original.png --out icon-64.png
|
||||||
|
sips -z 128 128 icon-original.png --out icon-128.png
|
||||||
|
sips -z 192 192 icon-original.png --out icon-192.png
|
||||||
|
sips -z 256 256 icon-original.png --out icon-256.png
|
||||||
|
sips -z 180 180 icon-original.png --out apple-touch-icon.png
|
||||||
|
|
||||||
|
# Create standard copies
|
||||||
|
cp icon-original.png icon-512.png
|
||||||
|
cp icon-original.png icon.png
|
||||||
|
cp icon-32.png favicon.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All icons maintain the VidBee bee/honeycomb theme
|
||||||
|
- Icons use PNG format with transparency (RGBA)
|
||||||
|
- Generated using macOS `sips` tool for quality consistency
|
||||||
BIN
docs/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
docs/public/browser-cookies.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
docs/public/cookies-file.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
docs/public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
docs/public/icon-128.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
docs/public/icon-16.png
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
docs/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/public/icon-256.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/public/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
docs/public/icon-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
docs/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/public/icon-64.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
docs/public/icon-original.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/public/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
46
docs/scripts/post-export.js
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-export script to copy English content to root directory
|
||||||
|
* This allows the default language (en) to be accessible without language prefix
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const outDir = join(__dirname, '../out');
|
||||||
|
const enDir = join(outDir, 'en');
|
||||||
|
|
||||||
|
console.log('Copying English content to root directory...');
|
||||||
|
|
||||||
|
if (!existsSync(enDir)) {
|
||||||
|
console.error('Error: /en directory not found in output');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all items in /en directory
|
||||||
|
const fs = await import('node:fs/promises');
|
||||||
|
const items = await fs.readdir(enDir);
|
||||||
|
|
||||||
|
// Copy each item to root, excluding already existing root items
|
||||||
|
for (const item of items) {
|
||||||
|
const source = join(enDir, item);
|
||||||
|
const dest = join(outDir, item);
|
||||||
|
|
||||||
|
// Skip if item already exists at root (like _next, api, etc.)
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
console.log(`Skipping ${item} (already exists at root)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cpSync(source, dest, { recursive: true });
|
||||||
|
console.log(`Copied ${item}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error copying ${item}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('English content copied to root directory successfully!');
|
||||||
22
docs/source.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config';
|
||||||
|
|
||||||
|
// You can customise Zod schemas for frontmatter and `meta.json` here
|
||||||
|
// see https://fumadocs.dev/docs/mdx/collections
|
||||||
|
export const docs = defineDocs({
|
||||||
|
dir: 'content',
|
||||||
|
docs: {
|
||||||
|
schema: frontmatterSchema,
|
||||||
|
postprocess: {
|
||||||
|
includeProcessedMarkdown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
schema: metaSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
mdxOptions: {
|
||||||
|
// MDX options
|
||||||
|
},
|
||||||
|
});
|
||||||
73
docs/src/app/[lang]/(docs)/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { getPageImage, source } from '@/lib/source';
|
||||||
|
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getMDXComponents } from '@/mdx-components';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||||
|
import { GitHubEditButton, LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps<'/[lang]/[[...slug]]'>) {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = source.getPage(params.slug, params.lang);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
const MDX = page.data.body;
|
||||||
|
const gitConfig = {
|
||||||
|
user: 'nexmoe',
|
||||||
|
repo: 'VidBee',
|
||||||
|
branch: 'main',
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubFilePath = `docs/content/${page.path}`;
|
||||||
|
const githubBlobUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${githubFilePath}`;
|
||||||
|
const githubEditUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/edit/${gitConfig.branch}/${githubFilePath}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||||
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
|
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||||
|
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||||
|
<GitHubEditButton href={githubEditUrl} />
|
||||||
|
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
|
||||||
|
<ViewOptions
|
||||||
|
markdownUrl={`${page.url}.mdx`}
|
||||||
|
githubUrl={githubBlobUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DocsBody>
|
||||||
|
<MDX
|
||||||
|
components={getMDXComponents({
|
||||||
|
// this allows you to link to other pages with relative file paths
|
||||||
|
a: createRelativeLink(source, page),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</DocsBody>
|
||||||
|
</DocsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return source.generateParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
props: PageProps<'/[lang]/[[...slug]]'>,
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = source.getPage(params.slug, params.lang);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
const baseUrl = 'https://docs.vidbee.org';
|
||||||
|
const canonicalUrl = `${baseUrl}${page.url}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${page.data.title} | VidBee Docs`,
|
||||||
|
description: page.data.description,
|
||||||
|
openGraph: {
|
||||||
|
images: getPageImage(page).url,
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: canonicalUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
20
docs/src/app/[lang]/(docs)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { source } from '@/lib/source';
|
||||||
|
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||||
|
import { baseOptions } from '@/lib/layout.shared';
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ lang: string; slug?: string[] }>;
|
||||||
|
}) {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const locale = resolvedParams.lang;
|
||||||
|
return (
|
||||||
|
<DocsLayout tree={source.getPageTree(locale)} {...baseOptions(locale)}>
|
||||||
|
{children}
|
||||||
|
</DocsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
docs/src/app/[lang]/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
|
import { i18n, isLocale } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const { provider } = defineI18nUI(i18n, {
|
||||||
|
translations: {
|
||||||
|
en: {
|
||||||
|
displayName: 'English',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
displayName: '中文',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ lang: string }>;
|
||||||
|
}) {
|
||||||
|
const { lang } = await params;
|
||||||
|
const locale = isLocale(lang) ? lang : i18n.defaultLanguage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootProvider
|
||||||
|
i18n={provider(locale)}
|
||||||
|
search={{
|
||||||
|
options: {
|
||||||
|
type: 'static',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RootProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
docs/src/app/api/search/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { source } from '@/lib/source';
|
||||||
|
import { createFromSource } from 'fumadocs-core/search/server';
|
||||||
|
|
||||||
|
// statically cached for static export
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
// Configure language support for both English and Chinese
|
||||||
|
export const { staticGET: GET } = createFromSource(source, {
|
||||||
|
localeMap: {
|
||||||
|
en: { language: 'english' },
|
||||||
|
// Chinese is not natively supported by Orama, use English tokenizer for zh
|
||||||
|
zh: { language: 'english' },
|
||||||
|
},
|
||||||
|
});
|
||||||
3
docs/src/app/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'fumadocs-ui/css/neutral.css';
|
||||||
|
@import 'fumadocs-ui/css/preset.css';
|
||||||
39
docs/src/app/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import './global.css';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { i18n, isLocale } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
{ url: '/icon-16.png', sizes: '16x16', type: 'image/png' },
|
||||||
|
{ url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: '/apple-touch-icon.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params?: Promise<{ lang?: string }>;
|
||||||
|
}) {
|
||||||
|
const resolvedParams = params ? await params : undefined;
|
||||||
|
const lang = isLocale(resolvedParams?.lang) ? resolvedParams.lang : i18n.defaultLanguage;
|
||||||
|
return (
|
||||||
|
<html lang={lang} className={inter.className} suppressHydrationWarning>
|
||||||
|
<body className="flex flex-col min-h-screen">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
docs/src/app/sitemap.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { MetadataRoute } from 'next';
|
||||||
|
import { i18n } from '@/lib/i18n';
|
||||||
|
import { source } from '@/lib/source';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
const baseUrl = 'https://docs.vidbee.org';
|
||||||
|
|
||||||
|
function buildPath(segments: string[]): string {
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return `/${segments.join('/')}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const params = source.generateParams();
|
||||||
|
|
||||||
|
return params.map(({ lang, slug }) => {
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
if (lang && lang !== i18n.defaultLanguage) {
|
||||||
|
segments.push(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug && slug.length > 0) {
|
||||||
|
segments.push(...slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${baseUrl}${buildPath(segments)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
250
docs/src/components/ai/page-actions.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
|
||||||
|
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function LLMCopyButton({
|
||||||
|
/**
|
||||||
|
* A URL to fetch the raw Markdown/MDX content of page
|
||||||
|
*/
|
||||||
|
markdownUrl,
|
||||||
|
}: {
|
||||||
|
markdownUrl: string;
|
||||||
|
}) {
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
const [checked, onClick] = useCopyButton(async () => {
|
||||||
|
const cached = cache.get(markdownUrl);
|
||||||
|
if (cached) return navigator.clipboard.writeText(cached);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/plain': fetch(markdownUrl).then(async (res) => {
|
||||||
|
const content = await res.text();
|
||||||
|
cache.set(markdownUrl, content);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
color: 'secondary',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{checked ? <Check /> : <Copy />}
|
||||||
|
Copy Markdown
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewOptions({
|
||||||
|
markdownUrl,
|
||||||
|
githubUrl,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* A URL to the raw Markdown/MDX content of page
|
||||||
|
*/
|
||||||
|
markdownUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source file URL on GitHub
|
||||||
|
*/
|
||||||
|
githubUrl: string;
|
||||||
|
}) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const fullMarkdownUrl =
|
||||||
|
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
|
||||||
|
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Open in GitHub',
|
||||||
|
href: githubUrl,
|
||||||
|
icon: (
|
||||||
|
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||||
|
<title>GitHub</title>
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Open in Scira AI',
|
||||||
|
href: `https://scira.ai/?${new URLSearchParams({
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="910"
|
||||||
|
height="934"
|
||||||
|
viewBox="0 0 910 934"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Scira AI</title>
|
||||||
|
<path
|
||||||
|
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="30"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Open in ChatGPT',
|
||||||
|
href: `https://chatgpt.com/?${new URLSearchParams({
|
||||||
|
hints: 'search',
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>OpenAI</title>
|
||||||
|
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Open in Claude',
|
||||||
|
href: `https://claude.ai/new?${new URLSearchParams({
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>Anthropic</title>
|
||||||
|
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Open in T3 Chat',
|
||||||
|
href: `https://t3.chat/new?${new URLSearchParams({
|
||||||
|
q,
|
||||||
|
})}`,
|
||||||
|
icon: <MessageCircleIcon />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [githubUrl, markdownUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
color: 'secondary',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'gap-2',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="flex flex-col">
|
||||||
|
{items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
className="text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.title}
|
||||||
|
<ExternalLinkIcon className="text-fd-muted-foreground size-3.5 ms-auto" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubEditButton({ href }: { href: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
color: 'secondary',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'gap-2',
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-3.5 text-fd-muted-foreground" />
|
||||||
|
Edit on GitHub
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
docs/src/lib/cn.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { twMerge as cn } from 'tailwind-merge';
|
||||||
34
docs/src/lib/i18n.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineI18n } from 'fumadocs-core/i18n';
|
||||||
|
|
||||||
|
export const i18n = defineI18n({
|
||||||
|
languages: ['en', 'zh'],
|
||||||
|
defaultLanguage: 'en',
|
||||||
|
// Hide locale prefix for default language (en) so English content appears at root
|
||||||
|
hideLocale: 'default-locale',
|
||||||
|
parser: 'dir'
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Locale = (typeof i18n.languages)[number];
|
||||||
|
|
||||||
|
const localeSet = new Set(i18n.languages);
|
||||||
|
|
||||||
|
export function isLocale(value?: string): value is Locale {
|
||||||
|
return Boolean(value && localeSet.has(value as Locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocaleFromSlug(slug?: string[]): Locale {
|
||||||
|
if (slug && slug.length > 0 && isLocale(slug[0])) {
|
||||||
|
return slug[0];
|
||||||
|
}
|
||||||
|
return i18n.defaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripLocaleFromSlug(slug?: string[]): string[] {
|
||||||
|
if (!slug || slug.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (isLocale(slug[0])) {
|
||||||
|
return slug.slice(1);
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
11
docs/src/lib/layout.shared.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||||
|
import { i18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export function baseOptions(_locale: string): BaseLayoutProps {
|
||||||
|
return {
|
||||||
|
nav: {
|
||||||
|
title: 'VidBee',
|
||||||
|
},
|
||||||
|
i18n,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
docs/src/lib/source.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { docs } from 'fumadocs-mdx:collections/server';
|
||||||
|
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||||
|
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||||
|
import { i18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||||
|
export const source = loader({
|
||||||
|
baseUrl: '/',
|
||||||
|
i18n,
|
||||||
|
source: docs.toFumadocsSource(),
|
||||||
|
plugins: [lucideIconsPlugin()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getPageImage(page: InferPageType<typeof source>) {
|
||||||
|
const localePrefix =
|
||||||
|
page.locale && page.locale !== i18n.defaultLanguage ? [page.locale] : [];
|
||||||
|
const segments = [...localePrefix, ...page.slugs, 'image.png'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
url: `/og/docs/${segments.join('/')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||||
|
const processed = await page.data.getText('processed');
|
||||||
|
|
||||||
|
return `# ${page.data.title}
|
||||||
|
|
||||||
|
${processed}`;
|
||||||
|
}
|
||||||
9
docs/src/mdx-components.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||||
|
import type { MDXComponents } from 'mdx/types';
|
||||||
|
|
||||||
|
export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||||
|
return {
|
||||||
|
...defaultMdxComponents,
|
||||||
|
...components,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
docs/src/middleware.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
|
||||||
|
import { i18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default createI18nMiddleware(i18n);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Note: Middleware doesn't run in static export, but kept for development
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
46
docs/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"fumadocs-mdx:collections/*": [
|
||||||
|
".source/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vidbee",
|
"name": "vidbee",
|
||||||
"version": "1.1.12",
|
"version": "1.2.1",
|
||||||
"description": "A modern Electron application for downloading videos and audios",
|
"description": "A modern Electron application for downloading videos and audios",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "VidBee",
|
"author": "VidBee",
|
||||||
|
|||||||
3
resources/.gitignore
vendored
@@ -6,6 +6,9 @@ ffmpeg.exe
|
|||||||
ffmpeg_macos
|
ffmpeg_macos
|
||||||
ffmpeg_linux
|
ffmpeg_linux
|
||||||
ffmpeg
|
ffmpeg
|
||||||
|
ffmpeg/
|
||||||
|
ffprobe
|
||||||
|
ffprobe.exe
|
||||||
deno.exe
|
deno.exe
|
||||||
deno
|
deno
|
||||||
|
|
||||||
|
|||||||
@@ -48,27 +48,27 @@ 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"
|
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
|
||||||
```
|
```
|
||||||
|
|
||||||
## ffmpeg Binaries
|
## ffmpeg/ffprobe Binaries
|
||||||
|
|
||||||
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
|
ffmpeg is required for merging audio/video streams and audio extraction. ffprobe is required for post-processing metadata. Bundle both binaries under `resources/ffmpeg/`.
|
||||||
|
|
||||||
### Required Files
|
### Required Files
|
||||||
|
|
||||||
1. **Windows**: `ffmpeg.exe`
|
1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe`
|
||||||
2. **macOS**: `ffmpeg_macos`
|
2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
|
||||||
3. **Linux**: `ffmpeg_linux`
|
3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
|
||||||
|
|
||||||
### How to Download
|
### 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.
|
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and copy `ffmpeg` and `ffprobe` into `resources/ffmpeg/`.
|
||||||
- **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`.
|
- **macOS**: Download the `ffmpeg-*.zip` asset from <https://github.com/eko5624/mpv-mac/releases/latest>, then copy `ffmpeg` and `ffprobe` from the archive into `resources/ffmpeg/`.
|
||||||
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
|
- On macOS/Linux ensure both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`.
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
|
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/ffprobe from the system PATH.
|
||||||
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
|
- You can override the lookup path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`.
|
||||||
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
|
- File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe)
|
||||||
|
|
||||||
## JS Runtime (Deno)
|
## JS Runtime (Deno)
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) {
|
|||||||
const binaries = [
|
const binaries = [
|
||||||
{
|
{
|
||||||
label: 'yt-dlp',
|
label: 'yt-dlp',
|
||||||
filenameMap: {
|
paths: {
|
||||||
win: 'yt-dlp.exe',
|
win: ['yt-dlp.exe'],
|
||||||
mac: 'yt-dlp_macos',
|
mac: ['yt-dlp_macos'],
|
||||||
linux: 'yt-dlp_linux'
|
linux: ['yt-dlp_linux']
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
|
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
|
||||||
@@ -34,10 +34,23 @@ const binaries = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'ffmpeg',
|
label: 'ffmpeg',
|
||||||
filenameMap: {
|
paths: {
|
||||||
win: 'ffmpeg.exe',
|
win: ['ffmpeg/ffmpeg.exe'],
|
||||||
mac: 'ffmpeg_macos',
|
mac: ['ffmpeg/ffmpeg'],
|
||||||
linux: 'ffmpeg_linux'
|
linux: ['ffmpeg/ffmpeg']
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
win: 'https://ffmpeg.org/download.html',
|
||||||
|
linux: 'https://ffmpeg.org/download.html',
|
||||||
|
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ffprobe',
|
||||||
|
paths: {
|
||||||
|
win: ['ffmpeg/ffprobe.exe'],
|
||||||
|
mac: ['ffmpeg/ffprobe'],
|
||||||
|
linux: ['ffmpeg/ffprobe']
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
win: 'https://ffmpeg.org/download.html',
|
win: 'https://ffmpeg.org/download.html',
|
||||||
@@ -47,10 +60,10 @@ const binaries = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'deno',
|
label: 'deno',
|
||||||
filenameMap: {
|
paths: {
|
||||||
win: 'deno.exe',
|
win: ['deno.exe'],
|
||||||
mac: 'deno',
|
mac: ['deno'],
|
||||||
linux: 'deno'
|
linux: ['deno']
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
default: 'https://github.com/denoland/deno/releases/latest'
|
default: 'https://github.com/denoland/deno/releases/latest'
|
||||||
@@ -61,12 +74,15 @@ const binaries = [
|
|||||||
let hasMissingBinary = false
|
let hasMissingBinary = false
|
||||||
|
|
||||||
for (const binary of binaries) {
|
for (const binary of binaries) {
|
||||||
const filename = binary.filenameMap[platform]
|
const candidates = binary.paths[platform] || []
|
||||||
const binaryPath = path.join(__dirname, '..', 'resources', filename)
|
const found = candidates.find((filename) =>
|
||||||
|
fs.existsSync(path.join(__dirname, '..', 'resources', filename))
|
||||||
|
)
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
if (!found) {
|
||||||
console.error(`❌ Error: resources/${filename} not found!`)
|
const expected = candidates.length ? candidates.join(' or ') : binary.label
|
||||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
console.error(`❌ Error: resources/${expected} not found!`)
|
||||||
|
console.error(`Please download ${binary.label} to the resources/ directory first.`)
|
||||||
const help =
|
const help =
|
||||||
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
|
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
|
||||||
if (help) {
|
if (help) {
|
||||||
@@ -74,7 +90,7 @@ for (const binary of binaries) {
|
|||||||
}
|
}
|
||||||
hasMissingBinary = true
|
hasMissingBinary = true
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ ${filename} found in resources/ directory`)
|
console.log(`✅ ${binary.label} found: resources/${found}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const http = require('node:http')
|
|||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||||
|
const FFMPEG_DIR = path.join(RESOURCES_DIR, 'ffmpeg')
|
||||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
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 DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||||
const GITHUB_TOKEN =
|
const GITHUB_TOKEN =
|
||||||
@@ -29,10 +30,12 @@ const PLATFORM_CONFIG = {
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
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',
|
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||||
|
ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',
|
||||||
output: 'ffmpeg.exe',
|
output: 'ffmpeg.exe',
|
||||||
|
ffprobeOutput: 'ffprobe.exe',
|
||||||
extract: 'unzip',
|
extract: 'unzip',
|
||||||
release: {
|
release: {
|
||||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
|
||||||
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||||
binaryName: 'ffmpeg.exe'
|
binaryName: 'ffmpeg.exe'
|
||||||
}
|
}
|
||||||
@@ -46,9 +49,11 @@ const PLATFORM_CONFIG = {
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
// For development, download only the architecture matching current system
|
// For development, download only the architecture matching current system
|
||||||
arm64: {
|
arm64: {
|
||||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip',
|
||||||
innerPath: 'ffmpeg/ffmpeg',
|
innerPath: 'ffmpeg/ffmpeg',
|
||||||
output: 'ffmpeg_macos',
|
ffprobeInnerPath: 'ffmpeg/ffprobe',
|
||||||
|
output: 'ffmpeg',
|
||||||
|
ffprobeOutput: 'ffprobe',
|
||||||
extract: 'unzip',
|
extract: 'unzip',
|
||||||
release: {
|
release: {
|
||||||
repo: 'eko5624/mpv-mac',
|
repo: 'eko5624/mpv-mac',
|
||||||
@@ -56,9 +61,11 @@ const PLATFORM_CONFIG = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
x64: {
|
x64: {
|
||||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip',
|
||||||
innerPath: 'ffmpeg/ffmpeg',
|
innerPath: 'ffmpeg/ffmpeg',
|
||||||
output: 'ffmpeg_macos',
|
ffprobeInnerPath: 'ffmpeg/ffprobe',
|
||||||
|
output: 'ffmpeg',
|
||||||
|
ffprobeOutput: 'ffprobe',
|
||||||
extract: 'unzip',
|
extract: 'unzip',
|
||||||
release: {
|
release: {
|
||||||
repo: 'eko5624/mpv-mac',
|
repo: 'eko5624/mpv-mac',
|
||||||
@@ -75,10 +82,12 @@ const PLATFORM_CONFIG = {
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
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',
|
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||||
output: 'ffmpeg_linux',
|
ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe',
|
||||||
|
output: 'ffmpeg',
|
||||||
|
ffprobeOutput: 'ffprobe',
|
||||||
extract: 'tar',
|
extract: 'tar',
|
||||||
release: {
|
release: {
|
||||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
|
||||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||||
binaryName: 'ffmpeg'
|
binaryName: 'ffmpeg'
|
||||||
}
|
}
|
||||||
@@ -329,15 +338,21 @@ function formatBytes(bytes) {
|
|||||||
return `${Math.round(bytes / 1024)} KB`
|
return `${Math.round(bytes / 1024)} KB`
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkBinary(filePath, args, label) {
|
function checkBinary(filePath, args, label, options = {}) {
|
||||||
|
const timeoutMs =
|
||||||
|
typeof options.timeoutMs === 'number'
|
||||||
|
? options.timeoutMs
|
||||||
|
: os.platform() === 'win32'
|
||||||
|
? 20000
|
||||||
|
: 8000
|
||||||
const result = spawnSync(filePath, args, {
|
const result = spawnSync(filePath, args, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 8000,
|
timeout: timeoutMs,
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { ok: false, message: result.error.message }
|
return { ok: false, message: result.error.message, code: result.error.code }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
@@ -413,23 +428,43 @@ async function downloadYtDlp(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFfmpegWindows(config) {
|
async function downloadFfmpegWindows(config) {
|
||||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
const {
|
||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
url: fallbackUrl,
|
||||||
|
innerPath: fallbackInnerPath,
|
||||||
|
ffprobeInnerPath: fallbackFfprobeInnerPath,
|
||||||
|
output,
|
||||||
|
ffprobeOutput,
|
||||||
|
release
|
||||||
|
} = config.ffmpeg
|
||||||
|
const outputPath = path.join(FFMPEG_DIR, output)
|
||||||
|
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
const ffmpegExists = fileExists(outputPath)
|
||||||
|
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||||
|
|
||||||
|
if (ffmpegExists && ffprobeExists) {
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
const ffprobeValidation = ffprobeOutputPath
|
||||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||||
|
: { ok: true }
|
||||||
|
if (!validation.ok || !ffprobeValidation.ok) {
|
||||||
|
log(
|
||||||
|
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||||
|
'warn'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||||
|
ensureDir(FFMPEG_DIR)
|
||||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
let downloadUrl = fallbackUrl
|
let downloadUrl = fallbackUrl
|
||||||
let innerPath = fallbackInnerPath
|
let innerPath = fallbackInnerPath
|
||||||
|
let ffprobeInnerPath = fallbackFfprobeInnerPath
|
||||||
|
|
||||||
if (release) {
|
if (release) {
|
||||||
try {
|
try {
|
||||||
@@ -440,6 +475,10 @@ async function downloadFfmpegWindows(config) {
|
|||||||
if (inferred) {
|
if (inferred) {
|
||||||
innerPath = inferred
|
innerPath = inferred
|
||||||
}
|
}
|
||||||
|
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')
|
||||||
|
if (inferredFfprobe) {
|
||||||
|
ffprobeInnerPath = inferredFfprobe
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||||
@@ -457,12 +496,24 @@ async function downloadFfmpegWindows(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
|
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||||
|
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath.replace(/\\/g, path.sep))
|
||||||
|
if (!fileExists(ffprobeSourcePath)) {
|
||||||
|
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||||
|
}
|
||||||
|
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||||
|
}
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
safeUnlink(outputPath)
|
if (validation.code === 'ETIMEDOUT') {
|
||||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
log(`Downloaded ${output} version check timed out; keeping binary`, 'warn')
|
||||||
|
} else {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
}
|
}
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
fs.unlinkSync(tempZip)
|
fs.unlinkSync(tempZip)
|
||||||
@@ -482,19 +533,38 @@ async function downloadFfmpegMac(config) {
|
|||||||
throw new Error(`Unsupported architecture: ${arch}`)
|
throw new Error(`Unsupported architecture: ${arch}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
|
const {
|
||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
url: fallbackUrl,
|
||||||
|
innerPath,
|
||||||
|
ffprobeInnerPath,
|
||||||
|
output,
|
||||||
|
ffprobeOutput,
|
||||||
|
release
|
||||||
|
} = ffmpegConfig
|
||||||
|
const outputPath = path.join(FFMPEG_DIR, output)
|
||||||
|
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
const ffmpegExists = fileExists(outputPath)
|
||||||
|
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||||
|
|
||||||
|
if (ffmpegExists && ffprobeExists) {
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
const ffprobeValidation = ffprobeOutputPath
|
||||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||||
|
: { ok: true }
|
||||||
|
if (!validation.ok || !ffprobeValidation.ok) {
|
||||||
|
log(
|
||||||
|
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||||
|
'warn'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||||
|
ensureDir(FFMPEG_DIR)
|
||||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
let downloadUrl = fallbackUrl
|
let downloadUrl = fallbackUrl
|
||||||
@@ -522,6 +592,14 @@ async function downloadFfmpegMac(config) {
|
|||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
|
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||||
|
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)
|
||||||
|
if (!fileExists(ffprobeSourcePath)) {
|
||||||
|
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||||
|
}
|
||||||
|
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||||
|
setExecutable(ffprobeOutputPath)
|
||||||
|
}
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
safeUnlink(outputPath)
|
safeUnlink(outputPath)
|
||||||
@@ -540,23 +618,43 @@ async function downloadFfmpegMac(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFfmpegLinux(config) {
|
async function downloadFfmpegLinux(config) {
|
||||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
const {
|
||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
url: fallbackUrl,
|
||||||
|
innerPath: fallbackInnerPath,
|
||||||
|
ffprobeInnerPath: fallbackFfprobeInnerPath,
|
||||||
|
output,
|
||||||
|
ffprobeOutput,
|
||||||
|
release
|
||||||
|
} = config.ffmpeg
|
||||||
|
const outputPath = path.join(FFMPEG_DIR, output)
|
||||||
|
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
const ffmpegExists = fileExists(outputPath)
|
||||||
|
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||||
|
|
||||||
|
if (ffmpegExists && ffprobeExists) {
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
const ffprobeValidation = ffprobeOutputPath
|
||||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||||
|
: { ok: true }
|
||||||
|
if (!validation.ok || !ffprobeValidation.ok) {
|
||||||
|
log(
|
||||||
|
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||||
|
'warn'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||||
|
ensureDir(FFMPEG_DIR)
|
||||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
let downloadUrl = fallbackUrl
|
let downloadUrl = fallbackUrl
|
||||||
let innerPath = fallbackInnerPath
|
let innerPath = fallbackInnerPath
|
||||||
|
let ffprobeInnerPath = fallbackFfprobeInnerPath
|
||||||
|
|
||||||
if (release) {
|
if (release) {
|
||||||
try {
|
try {
|
||||||
@@ -567,6 +665,10 @@ async function downloadFfmpegLinux(config) {
|
|||||||
if (inferred) {
|
if (inferred) {
|
||||||
innerPath = inferred
|
innerPath = inferred
|
||||||
}
|
}
|
||||||
|
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')
|
||||||
|
if (inferredFfprobe) {
|
||||||
|
ffprobeInnerPath = inferredFfprobe
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||||
@@ -585,6 +687,14 @@ async function downloadFfmpegLinux(config) {
|
|||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
|
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||||
|
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)
|
||||||
|
if (!fileExists(ffprobeSourcePath)) {
|
||||||
|
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||||
|
}
|
||||||
|
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||||
|
setExecutable(ffprobeOutputPath)
|
||||||
|
}
|
||||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
safeUnlink(outputPath)
|
safeUnlink(outputPath)
|
||||||
|
|||||||
@@ -71,14 +71,7 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
|||||||
const isBilibiliUrl = (url: string): boolean => {
|
const isBilibiliUrl = (url: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const host = new URL(url).hostname.toLowerCase()
|
const host = new URL(url).hostname.toLowerCase()
|
||||||
return (
|
return host.includes('bilibili.com') || host.includes('b23.tv') || host.includes('bili.tv')
|
||||||
host === 'bilibili.com' ||
|
|
||||||
host.endsWith('.bilibili.com') ||
|
|
||||||
host === 'b23.tv' ||
|
|
||||||
host.endsWith('.b23.tv') ||
|
|
||||||
host === 'bili.tv' ||
|
|
||||||
host.endsWith('.bili.tv')
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -98,7 +91,9 @@ export const buildDownloadArgs = (
|
|||||||
// Format selection
|
// Format selection
|
||||||
if (options.type === 'video') {
|
if (options.type === 'video') {
|
||||||
const formatSelector = resolveVideoFormatSelector(options)
|
const formatSelector = resolveVideoFormatSelector(options)
|
||||||
args.push('-f', formatSelector)
|
if (formatSelector) {
|
||||||
|
args.push('-f', formatSelector)
|
||||||
|
}
|
||||||
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
|
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
|
||||||
args.push('--audio-multistreams')
|
args.push('--audio-multistreams')
|
||||||
} else if (formatSelector.includes('mergeall')) {
|
} else if (formatSelector.includes('mergeall')) {
|
||||||
@@ -137,9 +132,7 @@ export const buildDownloadArgs = (
|
|||||||
} else {
|
} else {
|
||||||
args.push('--no-embed-subs')
|
args.push('--no-embed-subs')
|
||||||
}
|
}
|
||||||
if (process.platform !== 'darwin') {
|
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||||
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
|
||||||
}
|
|
||||||
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
||||||
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
||||||
|
|
||||||
@@ -152,8 +145,8 @@ export const buildDownloadArgs = (
|
|||||||
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||||
args.push('-o', outputTemplate)
|
args.push('-o', outputTemplate)
|
||||||
|
|
||||||
// Add options for better filename handling
|
// Allow resume support across restarts
|
||||||
args.push('--no-part')
|
args.push('--continue')
|
||||||
args.push('--no-playlist-reverse')
|
args.push('--no-playlist-reverse')
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ function setupDownloadEvents(): void {
|
|||||||
mainWindow?.webContents.send('download:progress', { id, progress })
|
mainWindow?.webContents.send('download:progress', { id, progress })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
downloadEngine.on('download-log', (id: string, logText: string) => {
|
||||||
|
mainWindow?.webContents.send('download:log', { id, log: logText })
|
||||||
|
})
|
||||||
|
|
||||||
downloadEngine.on('download-completed', (id: string) => {
|
downloadEngine.on('download-completed', (id: string) => {
|
||||||
mainWindow?.webContents.send('download:completed', id)
|
mainWindow?.webContents.send('download:completed', id)
|
||||||
})
|
})
|
||||||
@@ -449,14 +453,20 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize yt-dlp
|
// Initialize yt-dlp
|
||||||
|
let ytdlpReady = false
|
||||||
try {
|
try {
|
||||||
log.info('Initializing yt-dlp...')
|
log.info('Initializing yt-dlp...')
|
||||||
await ytdlpManager.initialize()
|
await ytdlpManager.initialize()
|
||||||
|
ytdlpReady = true
|
||||||
log.info('yt-dlp initialized successfully')
|
log.info('yt-dlp initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to initialize yt-dlp:', error)
|
log.error('Failed to initialize yt-dlp:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ytdlpReady) {
|
||||||
|
downloadEngine.restoreActiveDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
await startExtensionApiServer()
|
await startExtensionApiServer()
|
||||||
|
|
||||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||||
@@ -474,14 +484,27 @@ app.whenReady().then(async () => {
|
|||||||
handleDeepLinkArgv(process.argv)
|
handleDeepLinkArgv(process.argv)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
const existingWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
|
||||||
|
if (existingWindow) {
|
||||||
|
if (existingWindow.isMinimized()) {
|
||||||
|
existingWindow.restore()
|
||||||
|
}
|
||||||
|
if (!existingWindow.isVisible()) {
|
||||||
|
existingWindow.show()
|
||||||
|
}
|
||||||
|
existingWindow.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// 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.
|
// dock icon is clicked and there are no other windows open.
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
isQuitting = true
|
isQuitting = true
|
||||||
|
downloadEngine.flushDownloadSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
// Quit when all windows are closed, except on macOS. There, it's common
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class DownloadService extends IpcService {
|
|||||||
return downloadEngine.getQueueStatus()
|
return downloadEngine.getQueueStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
getActiveDownloads(_context: IpcContext): DownloadItem[] {
|
||||||
|
return downloadEngine.getActiveDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
|
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
|
||||||
downloadEngine.updateDownloadInfo(id, updates)
|
downloadEngine.updateDownloadInfo(id, updates)
|
||||||
|
|||||||
@@ -106,6 +106,35 @@ class FileSystemService extends IpcService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
async openFile(_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)
|
||||||
|
|
||||||
|
if (!stats || (!stats.isFile() && !stats.isDirectory())) {
|
||||||
|
scopedLoggers.system.error('File does not exist:', normalizedPath)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await shell.openPath(normalizedPath)
|
||||||
|
if (result) {
|
||||||
|
scopedLoggers.system.error('Failed to open file:', result)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.system.error('Failed to open file:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
|
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
SubscriptionRule,
|
SubscriptionRule,
|
||||||
SubscriptionUpdatePayload
|
SubscriptionUpdatePayload
|
||||||
} from '../../../shared/types'
|
} from '../../../shared/types'
|
||||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
|
import {
|
||||||
|
DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE,
|
||||||
|
SUBSCRIPTION_DUPLICATE_FEED_ERROR
|
||||||
|
} from '../../../shared/types'
|
||||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||||
@@ -113,6 +116,10 @@ class SubscriptionService extends IpcService {
|
|||||||
options: CreateSubscriptionOptions
|
options: CreateSubscriptionOptions
|
||||||
): Promise<SubscriptionRule> {
|
): Promise<SubscriptionRule> {
|
||||||
const resolved = resolveFeedFromInput(options.url)
|
const resolved = resolveFeedFromInput(options.url)
|
||||||
|
const duplicate = subscriptionManager.findDuplicateFeed(resolved.feedUrl)
|
||||||
|
if (duplicate) {
|
||||||
|
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
|
||||||
|
}
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||||
const payload: SubscriptionCreatePayload = {
|
const payload: SubscriptionCreatePayload = {
|
||||||
@@ -141,6 +148,12 @@ class SubscriptionService extends IpcService {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: SubscriptionUpdatePayload
|
updates: SubscriptionUpdatePayload
|
||||||
): SubscriptionRule | undefined {
|
): SubscriptionRule | undefined {
|
||||||
|
if (updates.feedUrl) {
|
||||||
|
const duplicate = subscriptionManager.findDuplicateFeed(updates.feedUrl, id)
|
||||||
|
if (duplicate) {
|
||||||
|
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
const normalized: SubscriptionUpdatePayload = { ...updates }
|
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||||
if (typeof normalized.namingTemplate === 'string') {
|
if (typeof normalized.namingTemplate === 'string') {
|
||||||
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const downloadHistoryTable = sqliteTable('download_history', {
|
|||||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||||
error: text('error'),
|
error: text('error'),
|
||||||
ytDlpCommand: text('yt_dlp_command'),
|
ytDlpCommand: text('yt_dlp_command'),
|
||||||
|
ytDlpLog: text('yt_dlp_log'),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
channel: text('channel'),
|
channel: text('channel'),
|
||||||
uploader: text('uploader'),
|
uploader: text('uploader'),
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import { settingsManager } from '../settings'
|
|||||||
import { scopedLoggers } from '../utils/logger'
|
import { scopedLoggers } from '../utils/logger'
|
||||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||||
import { DownloadQueue } from './download-queue'
|
import { DownloadQueue } from './download-queue'
|
||||||
|
import {
|
||||||
|
type DownloadSessionItem,
|
||||||
|
loadDownloadSession,
|
||||||
|
saveDownloadSession
|
||||||
|
} from './download-session-store'
|
||||||
import { ffmpegManager } from './ffmpeg-manager'
|
import { ffmpegManager } from './ffmpeg-manager'
|
||||||
import { historyManager } from './history-manager'
|
import { historyManager } from './history-manager'
|
||||||
import { ytdlpManager } from './ytdlp-manager'
|
import { ytdlpManager } from './ytdlp-manager'
|
||||||
@@ -50,6 +55,8 @@ const formatYtDlpCommand = (args: string[]): string => {
|
|||||||
return `yt-dlp ${quoted.join(' ')}`
|
return `yt-dlp ${quoted.join(' ')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath)
|
||||||
|
|
||||||
const ensureDirectoryExists = (dir?: string): void => {
|
const ensureDirectoryExists = (dir?: string): void => {
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
return
|
return
|
||||||
@@ -260,6 +267,8 @@ const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsMan
|
|||||||
class DownloadEngine extends EventEmitter {
|
class DownloadEngine extends EventEmitter {
|
||||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||||
private queue: DownloadQueue
|
private queue: DownloadQueue
|
||||||
|
private sessionPersistTimer: NodeJS.Timeout | null = null
|
||||||
|
private sessionRestored = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -269,6 +278,10 @@ class DownloadEngine extends EventEmitter {
|
|||||||
this.queue.on('start-download', async (item) => {
|
this.queue.on('start-download', async (item) => {
|
||||||
await this.executeDownload(item.id, item.options)
|
await this.executeDownload(item.id, item.options)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.queue.on('queue-updated', () => {
|
||||||
|
this.scheduleSessionPersist()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||||
@@ -615,16 +628,34 @@ class DownloadEngine extends EventEmitter {
|
|||||||
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
|
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const normalizedTitles = new Set<string>()
|
||||||
|
let hasDuplicateTitles = false
|
||||||
|
for (const entry of selectedEntries) {
|
||||||
|
const key = sanitizeTemplateValue(entry.title || '').toLowerCase()
|
||||||
|
if (normalizedTitles.has(key)) {
|
||||||
|
hasDuplicateTitles = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
normalizedTitles.add(key)
|
||||||
|
}
|
||||||
|
const indexWidth = hasDuplicateTitles
|
||||||
|
? String(Math.max(...selectedEntries.map((entry) => entry.index))).length
|
||||||
|
: 0
|
||||||
|
|
||||||
// Create download items for each video in the playlist
|
// Create download items for each video in the playlist
|
||||||
for (const entry of selectedEntries) {
|
for (const entry of selectedEntries) {
|
||||||
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
||||||
|
const customFilenameTemplate = hasDuplicateTitles
|
||||||
|
? `${String(entry.index).padStart(indexWidth, '0')} - %(title)s via VidBee.%(ext)s`
|
||||||
|
: undefined
|
||||||
|
|
||||||
const downloadOptions: DownloadOptions = {
|
const downloadOptions: DownloadOptions = {
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
type: options.type,
|
type: options.type,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
audioFormat: options.type === 'audio' ? options.format : undefined,
|
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||||
customDownloadPath: resolvedDownloadPath
|
customDownloadPath: resolvedDownloadPath,
|
||||||
|
customFilenameTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
@@ -738,6 +769,46 @@ class DownloadEngine extends EventEmitter {
|
|||||||
let totalParts = estimateProgressParts(options)
|
let totalParts = estimateProgressParts(options)
|
||||||
let completedParts = 0
|
let completedParts = 0
|
||||||
let lastPercent = 0
|
let lastPercent = 0
|
||||||
|
let ytDlpLog = ''
|
||||||
|
let logFlushTimer: NodeJS.Timeout | null = null
|
||||||
|
let lastFlushedLog = ''
|
||||||
|
|
||||||
|
const normalizeLogChunk = (chunk: string): string =>
|
||||||
|
chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
|
||||||
|
const flushLogUpdate = (): void => {
|
||||||
|
if (logFlushTimer) {
|
||||||
|
clearTimeout(logFlushTimer)
|
||||||
|
logFlushTimer = null
|
||||||
|
}
|
||||||
|
if (ytDlpLog === lastFlushedLog) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFlushedLog = ytDlpLog
|
||||||
|
this.updateDownloadInfo(id, { ytDlpLog })
|
||||||
|
this.emit('download-log', id, ytDlpLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleLogUpdate = (): void => {
|
||||||
|
if (logFlushTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logFlushTimer = setTimeout(() => {
|
||||||
|
flushLogUpdate()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendLogChunk = (chunk: string | Buffer): void => {
|
||||||
|
if (!chunk) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = typeof chunk === 'string' ? chunk : chunk.toString()
|
||||||
|
if (!text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ytDlpLog += normalizeLogChunk(text)
|
||||||
|
scheduleLogUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
// First, get detailed video info to capture basic metadata and formats
|
// First, get detailed video info to capture basic metadata and formats
|
||||||
try {
|
try {
|
||||||
@@ -897,7 +968,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push('--ffmpeg-location', ffmpegPath)
|
const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)
|
||||||
|
args.push('--ffmpeg-location', ffmpegLocation)
|
||||||
args.push(urlArg)
|
args.push(urlArg)
|
||||||
|
|
||||||
const ytDlpCommand = formatYtDlpCommand(args)
|
const ytDlpCommand = formatYtDlpCommand(args)
|
||||||
@@ -911,6 +983,16 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
||||||
|
|
||||||
|
ytdlpProcess.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
|
||||||
|
appendLogChunk(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
ytdlpProcess.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
|
||||||
|
appendLogChunk(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })
|
||||||
|
this.scheduleSessionPersist()
|
||||||
this.emit('download-started', id)
|
this.emit('download-started', id)
|
||||||
|
|
||||||
this.upsertHistoryEntry(id, options, {
|
this.upsertHistoryEntry(id, options, {
|
||||||
@@ -964,6 +1046,11 @@ class DownloadEngine extends EventEmitter {
|
|||||||
downloaded: progress.downloaded || '',
|
downloaded: progress.downloaded || '',
|
||||||
total: progress.total || ''
|
total: progress.total || ''
|
||||||
}
|
}
|
||||||
|
this.queue.updateItemInfo(id, {
|
||||||
|
progress: downloadProgress,
|
||||||
|
speed: downloadProgress.currentSpeed || ''
|
||||||
|
})
|
||||||
|
this.scheduleSessionPersist()
|
||||||
this.emit('download-progress', id, downloadProgress)
|
this.emit('download-progress', id, downloadProgress)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1003,6 +1090,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Handle completion
|
// Handle completion
|
||||||
ytdlpProcess.on('close', async (code: number | null) => {
|
ytdlpProcess.on('close', async (code: number | null) => {
|
||||||
|
flushLogUpdate()
|
||||||
this.activeDownloads.delete(id)
|
this.activeDownloads.delete(id)
|
||||||
this.queue.downloadCompleted(id)
|
this.queue.downloadCompleted(id)
|
||||||
|
|
||||||
@@ -1133,6 +1221,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
ytdlpProcess.on('error', (error: Error) => {
|
ytdlpProcess.on('error', (error: Error) => {
|
||||||
|
flushLogUpdate()
|
||||||
scopedLoggers.download.error('Download process error for ID:', id, error)
|
scopedLoggers.download.error('Download process error for ID:', id, error)
|
||||||
this.activeDownloads.delete(id)
|
this.activeDownloads.delete(id)
|
||||||
this.queue.downloadCompleted(id)
|
this.queue.downloadCompleted(id)
|
||||||
@@ -1178,6 +1267,72 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return this.queue.getQueueStatus()
|
return this.queue.getQueueStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveDownloads(): DownloadItem[] {
|
||||||
|
const items = new Map<string, DownloadItem>()
|
||||||
|
for (const item of this.queue.getActiveItems()) {
|
||||||
|
items.set(item.id, item)
|
||||||
|
}
|
||||||
|
for (const item of this.queue.getQueuedItems()) {
|
||||||
|
items.set(item.id, item)
|
||||||
|
}
|
||||||
|
return Array.from(items.values()).sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreActiveDownloads(): void {
|
||||||
|
if (this.sessionRestored) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sessionRestored = true
|
||||||
|
|
||||||
|
const sessionItems = loadDownloadSession()
|
||||||
|
if (sessionItems.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of sessionItems) {
|
||||||
|
if (!entry?.id || !entry.options?.url || !entry.options.type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (this.queue.getItemDetails(entry.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyItem = historyManager.getHistoryById(entry.id)
|
||||||
|
if (historyItem && ['completed', 'error', 'cancelled'].includes(historyItem.status)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = entry.item?.createdAt ?? Date.now()
|
||||||
|
const restoredItem: DownloadItem = {
|
||||||
|
...entry.item,
|
||||||
|
id: entry.id,
|
||||||
|
url: entry.options.url,
|
||||||
|
type: entry.options.type,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt,
|
||||||
|
completedAt: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.add(entry.id, entry.options, restoredItem)
|
||||||
|
|
||||||
|
this.upsertHistoryEntry(entry.id, entry.options, {
|
||||||
|
title: restoredItem.title || historyItem?.title || `Download ${entry.id}`,
|
||||||
|
status: 'pending',
|
||||||
|
downloadedAt: historyItem?.downloadedAt ?? createdAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleSessionPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
flushDownloadSession(): void {
|
||||||
|
if (this.sessionPersistTimer) {
|
||||||
|
clearTimeout(this.sessionPersistTimer)
|
||||||
|
this.sessionPersistTimer = null
|
||||||
|
}
|
||||||
|
this.persistSession()
|
||||||
|
}
|
||||||
|
|
||||||
updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {
|
updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {
|
||||||
this.queue.updateItemInfo(id, updates)
|
this.queue.updateItemInfo(id, updates)
|
||||||
|
|
||||||
@@ -1242,6 +1397,9 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (updates.ytDlpCommand !== undefined) {
|
if (updates.ytDlpCommand !== undefined) {
|
||||||
historyUpdates.ytDlpCommand = updates.ytDlpCommand
|
historyUpdates.ytDlpCommand = updates.ytDlpCommand
|
||||||
}
|
}
|
||||||
|
if (updates.ytDlpLog !== undefined) {
|
||||||
|
historyUpdates.ytDlpLog = updates.ytDlpLog
|
||||||
|
}
|
||||||
if (updates.savedFileName !== undefined) {
|
if (updates.savedFileName !== undefined) {
|
||||||
historyUpdates.savedFileName = updates.savedFileName
|
historyUpdates.savedFileName = updates.savedFileName
|
||||||
}
|
}
|
||||||
@@ -1249,6 +1407,37 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (Object.keys(historyUpdates).length > 0) {
|
if (Object.keys(historyUpdates).length > 0) {
|
||||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleSessionPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSessionPersist(): void {
|
||||||
|
if (this.sessionPersistTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sessionPersistTimer = setTimeout(() => {
|
||||||
|
this.sessionPersistTimer = null
|
||||||
|
this.persistSession()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSession(): void {
|
||||||
|
const entries: DownloadSessionItem[] = []
|
||||||
|
const activeEntries = this.queue.getActiveEntries()
|
||||||
|
const queuedEntries = this.queue.getQueuedEntries()
|
||||||
|
|
||||||
|
for (const entry of [...activeEntries, ...queuedEntries]) {
|
||||||
|
if (!entry?.item?.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
id: entry.item.id,
|
||||||
|
options: entry.options,
|
||||||
|
item: entry.item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDownloadSession(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
private addToHistory(
|
private addToHistory(
|
||||||
@@ -1259,7 +1448,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
): void {
|
): void {
|
||||||
// Get the download item from the queue to get additional info
|
// Get the download item from the queue to get additional info
|
||||||
const completedDownload = this.queue.getCompletedDownload(id)
|
const completedDownload = this.queue.getCompletedDownload(id)
|
||||||
scopedLoggers.download.info('Completed download:', completedDownload)
|
// scopedLoggers.download.info('Completed download:', completedDownload)
|
||||||
const completedAt = Date.now()
|
const completedAt = Date.now()
|
||||||
|
|
||||||
this.upsertHistoryEntry(id, options, {
|
this.upsertHistoryEntry(id, options, {
|
||||||
@@ -1307,6 +1496,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
completedAt: updates.completedAt,
|
completedAt: updates.completedAt,
|
||||||
error: updates.error,
|
error: updates.error,
|
||||||
ytDlpCommand: updates.ytDlpCommand,
|
ytDlpCommand: updates.ytDlpCommand,
|
||||||
|
ytDlpLog: updates.ytDlpLog,
|
||||||
description: updates.description,
|
description: updates.description,
|
||||||
channel: updates.channel,
|
channel: updates.channel,
|
||||||
uploader: updates.uploader,
|
uploader: updates.uploader,
|
||||||
|
|||||||
@@ -82,6 +82,28 @@ export class DownloadQueue extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveItems(): DownloadItem[] {
|
||||||
|
return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueuedItems(): DownloadItem[] {
|
||||||
|
return this.queue.map((item) => ({ ...item.item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||||
|
return Array.from(this.activeDownloads.values()).map((entry) => ({
|
||||||
|
options: { ...entry.options },
|
||||||
|
item: { ...entry.item }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||||
|
return this.queue.map((entry) => ({
|
||||||
|
options: { ...entry.options },
|
||||||
|
item: { ...entry.item }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
isDownloading(id: string): boolean {
|
isDownloading(id: string): boolean {
|
||||||
return this.activeDownloads.has(id)
|
return this.activeDownloads.has(id)
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/main/lib/download-session-store.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import type { DownloadItem, DownloadOptions } from '../../shared/types'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
|
export interface DownloadSessionItem {
|
||||||
|
id: string
|
||||||
|
options: DownloadOptions
|
||||||
|
item: DownloadItem
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadSessionPayload {
|
||||||
|
version: 1
|
||||||
|
updatedAt: number
|
||||||
|
items: DownloadSessionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_FILE_NAME = 'download-session.json'
|
||||||
|
|
||||||
|
const getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME)
|
||||||
|
|
||||||
|
const isValidItem = (item: DownloadSessionItem): boolean =>
|
||||||
|
Boolean(item?.id && item.options && item.item)
|
||||||
|
|
||||||
|
export const loadDownloadSession = (): DownloadSessionItem[] => {
|
||||||
|
const filePath = getSessionFilePath()
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const payload = JSON.parse(raw) as DownloadSessionPayload
|
||||||
|
if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return payload.items.filter(isValidItem)
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.download.warn('Failed to load download session:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveDownloadSession = (items: DownloadSessionItem[]): void => {
|
||||||
|
const filePath = getSessionFilePath()
|
||||||
|
if (items.length === 0) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(filePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.download.warn('Failed to clear download session:', error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: DownloadSessionPayload = {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.download.warn('Failed to save download session:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,46 +28,58 @@ class FfmpegManager {
|
|||||||
|
|
||||||
private async findFfmpegBinary(): Promise<string> {
|
private async findFfmpegBinary(): Promise<string> {
|
||||||
const platform = os.platform()
|
const platform = os.platform()
|
||||||
const resourceCandidates: string[] = []
|
const ffmpegFileName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
const ffprobeFileName = platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'
|
||||||
|
|
||||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => {
|
||||||
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
const ffmpegPath = path.join(dirPath, ffmpegFileName)
|
||||||
return process.env.FFMPEG_PATH
|
const ffprobePath = path.join(dirPath, ffprobeFileName)
|
||||||
|
if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
fs.chmodSync(ffmpegPath, 0o755)
|
||||||
|
fs.chmodSync(ffprobePath, 0o755)
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.engine.warn(`Failed to set executable permission on ${label}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopedLoggers.engine.info(`Using ${label}:`, ffmpegPath)
|
||||||
|
return ffmpegPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'win32') {
|
const envPath = process.env.FFMPEG_PATH
|
||||||
resourceCandidates.push('ffmpeg.exe')
|
if (envPath) {
|
||||||
} else if (platform === 'darwin') {
|
if (!fs.existsSync(envPath)) {
|
||||||
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
|
throw new Error(
|
||||||
} else {
|
'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.'
|
||||||
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
|
)
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(envPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error('FFMPEG_PATH must be a directory containing ffmpeg and ffprobe.')
|
||||||
|
}
|
||||||
|
const resolved = resolveBundledFfmpeg(envPath, 'ffmpeg from FFMPEG_PATH directory')
|
||||||
|
if (resolved) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
throw new Error('FFMPEG_PATH must contain both ffmpeg and ffprobe.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourcesPath = this.getResourcesPath()
|
const resourcesPath = this.getResourcesPath()
|
||||||
for (const candidate of resourceCandidates) {
|
const bundledDir = path.join(resourcesPath, 'ffmpeg')
|
||||||
const fullPath = path.join(resourcesPath, candidate)
|
const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg')
|
||||||
if (fs.existsSync(fullPath)) {
|
if (bundledResolved) {
|
||||||
if (platform !== 'win32') {
|
return bundledResolved
|
||||||
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') {
|
if (platform === 'darwin') {
|
||||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin']
|
||||||
for (const candidate of commonPaths) {
|
for (const candidate of commonDirs) {
|
||||||
if (fs.existsSync(candidate)) {
|
const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg')
|
||||||
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
if (resolved) {
|
||||||
return candidate
|
return resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,8 +88,10 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||||
if (systemPath && fs.existsSync(systemPath)) {
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')
|
||||||
return systemPath
|
if (resolved) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore error and continue
|
// Ignore error and continue
|
||||||
@@ -88,8 +102,10 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||||
if (output && fs.existsSync(output)) {
|
if (output && fs.existsSync(output)) {
|
||||||
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')
|
||||||
return output
|
if (resolved) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore error and continue
|
// Ignore error and continue
|
||||||
@@ -97,7 +113,7 @@ class FfmpegManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
|
'ffmpeg/ffprobe not found. Bundle them under resources/ffmpeg/ (asarUnpack) or set FFMPEG_PATH to a directory containing both.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const createDownloadHistoryTableSql = sql`
|
|||||||
sort_key INTEGER NOT NULL,
|
sort_key INTEGER NOT NULL,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
yt_dlp_command TEXT,
|
yt_dlp_command TEXT,
|
||||||
|
yt_dlp_log TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
channel TEXT,
|
channel TEXT,
|
||||||
uploader TEXT,
|
uploader TEXT,
|
||||||
@@ -219,20 +220,53 @@ class HistoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||||
const requiredColumns = ['yt_dlp_command']
|
const requiredColumns = ['yt_dlp_command', 'yt_dlp_log']
|
||||||
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
|
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||||
const missingRequired = requiredColumns.some(
|
const missingRequired = requiredColumns.filter(
|
||||||
(columnName) => !columns.some((column) => column.name === columnName)
|
(columnName) => !columns.some((column) => column.name === columnName)
|
||||||
)
|
)
|
||||||
const needsRebuild = hasDeprecated || missingRequired
|
if (hasDeprecated) {
|
||||||
if (needsRebuild) {
|
|
||||||
this.rebuildDownloadHistoryTable()
|
this.rebuildDownloadHistoryTable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (missingRequired.length > 0) {
|
||||||
|
this.addMissingColumns(missingRequired)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('history-db failed to inspect schema', error)
|
logger.error('history-db failed to inspect schema', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addMissingColumns(columns: string[]): void {
|
||||||
|
if (columns.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const definitions: Record<string, string> = {
|
||||||
|
yt_dlp_command: 'TEXT',
|
||||||
|
yt_dlp_log: 'TEXT'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
for (const column of columns) {
|
||||||
|
const definition = definitions[column]
|
||||||
|
if (!definition) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx.run(sql.raw(`ALTER TABLE download_history ADD COLUMN ${column} ${definition}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ behavior: 'immediate' }
|
||||||
|
)
|
||||||
|
logger.info(`history-db added missing columns: ${columns.join(', ')}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to add missing columns', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private migrateLegacyPayloadTable(): void {
|
private migrateLegacyPayloadTable(): void {
|
||||||
const database = this.getDatabase()
|
const database = this.getDatabase()
|
||||||
logger.info('history-db migrating legacy payload schema to structured columns')
|
logger.info('history-db migrating legacy payload schema to structured columns')
|
||||||
@@ -394,6 +428,7 @@ class HistoryManager {
|
|||||||
sortKey: item.completedAt ?? item.downloadedAt,
|
sortKey: item.completedAt ?? item.downloadedAt,
|
||||||
error: item.error ?? null,
|
error: item.error ?? null,
|
||||||
ytDlpCommand: item.ytDlpCommand ?? null,
|
ytDlpCommand: item.ytDlpCommand ?? null,
|
||||||
|
ytDlpLog: item.ytDlpLog ?? null,
|
||||||
description: item.description ?? null,
|
description: item.description ?? null,
|
||||||
channel: item.channel ?? null,
|
channel: item.channel ?? null,
|
||||||
uploader: item.uploader ?? null,
|
uploader: item.uploader ?? null,
|
||||||
@@ -441,6 +476,7 @@ class HistoryManager {
|
|||||||
completedAt: row.completedAt ?? undefined,
|
completedAt: row.completedAt ?? undefined,
|
||||||
error: row.error ?? undefined,
|
error: row.error ?? undefined,
|
||||||
ytDlpCommand: row.ytDlpCommand ?? undefined,
|
ytDlpCommand: row.ytDlpCommand ?? undefined,
|
||||||
|
ytDlpLog: row.ytDlpLog ?? undefined,
|
||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
channel: row.channel ?? undefined,
|
channel: row.channel ?? undefined,
|
||||||
uploader: row.uploader ?? undefined,
|
uploader: row.uploader ?? undefined,
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter {
|
|||||||
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findDuplicateFeed(
|
||||||
|
feedUrl: string,
|
||||||
|
ignoreId?: string
|
||||||
|
): { id: string; feedUrl: string } | undefined {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const rows = database
|
||||||
|
.select({ id: subscriptionsTable.id, feedUrl: subscriptionsTable.feedUrl })
|
||||||
|
.from(subscriptionsTable)
|
||||||
|
.all()
|
||||||
|
const targetKey = this.buildFeedKey(feedUrl)
|
||||||
|
if (!targetKey) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return rows.find((row) => row.id !== ignoreId && this.buildFeedKey(row.feedUrl) === targetKey)
|
||||||
|
}
|
||||||
|
|
||||||
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const keywords = sanitizeList(payload.keywords)
|
const keywords = sanitizeList(payload.keywords)
|
||||||
@@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter {
|
|||||||
return this.db
|
return this.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildFeedKey(feedUrl: string): string {
|
||||||
|
const trimmed = feedUrl.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized)
|
||||||
|
let pathname = url.pathname || '/'
|
||||||
|
pathname = pathname.replace(/\/+$/, '')
|
||||||
|
if (!pathname) {
|
||||||
|
pathname = '/'
|
||||||
|
}
|
||||||
|
return `${url.host.toLowerCase()}${pathname}${url.search}`
|
||||||
|
} catch {
|
||||||
|
return trimmed.toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureItemsSchema(): void {
|
private ensureItemsSchema(): void {
|
||||||
if (!this.sqlite) {
|
if (!this.sqlite) {
|
||||||
return
|
return
|
||||||
|
|||||||
2
src/preload/index.d.ts
vendored
@@ -5,7 +5,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: IpcServices & {
|
api: IpcServices & {
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => void
|
on: (channel: string, callback: (...args: unknown[]) => void) => (...args: unknown[]) => void
|
||||||
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
|
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
|
||||||
send: (channel: string, ...args: unknown[]) => void
|
send: (channel: string, ...args: unknown[]) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ErrorBoundary } from './components/error/ErrorBoundary'
|
import { ErrorBoundary } from './components/error/ErrorBoundary'
|
||||||
|
import { useDownloadEvents } from './hooks/use-download-events'
|
||||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||||
import { About } from './pages/About'
|
import { About } from './pages/About'
|
||||||
import { Home } from './pages/Home'
|
import { Home } from './pages/Home'
|
||||||
@@ -62,6 +63,8 @@ function AppContent() {
|
|||||||
const currentPage = pathToPage(location.pathname)
|
const currentPage = pathToPage(location.pathname)
|
||||||
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||||
|
|
||||||
|
useDownloadEvents()
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(page: Page) => {
|
(page: Page) => {
|
||||||
const targetPath = pageToPath[page] ?? '/'
|
const targetPath = pageToPath[page] ?? '/'
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||||
import {
|
import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads'
|
||||||
addDownloadAtom,
|
|
||||||
addHistoryRecordAtom,
|
|
||||||
removeDownloadAtom,
|
|
||||||
updateDownloadAtom
|
|
||||||
} from '../../store/downloads'
|
|
||||||
import { loadSettingsAtom, settingsAtom } from '../../store/settings'
|
import { loadSettingsAtom, settingsAtom } from '../../store/settings'
|
||||||
import {
|
import {
|
||||||
currentVideoInfoAtom,
|
currentVideoInfoAtom,
|
||||||
@@ -116,8 +111,6 @@ export function DownloadDialog({
|
|||||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
const updateDownload = useSetAtom(updateDownloadAtom)
|
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||||
const addDownload = useSetAtom(addDownloadAtom)
|
const addDownload = useSetAtom(addDownloadAtom)
|
||||||
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
|
|
||||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||||
@@ -182,21 +175,6 @@ export function DownloadDialog({
|
|||||||
)
|
)
|
||||||
}, [playlistInfo, computePlaylistRange, selectedEntryIds])
|
}, [playlistInfo, computePlaylistRange, selectedEntryIds])
|
||||||
|
|
||||||
const syncHistoryItem = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
try {
|
|
||||||
const historyItem = await ipcServices.history.getHistoryById(id)
|
|
||||||
if (historyItem) {
|
|
||||||
addHistoryRecord(historyItem)
|
|
||||||
removeDownload(id)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to sync history item:', error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addHistoryRecord, removeDownload]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Listen for deep link events
|
// Listen for deep link events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDeepLink = async (data: unknown) => {
|
const handleDeepLink = async (data: unknown) => {
|
||||||
@@ -283,69 +261,8 @@ export function DownloadDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
// Load settings when dialog opens
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
}, [open, loadSettings])
|
||||||
// Listen for download events from main process
|
|
||||||
ipcEvents.on('download:started', (...args: unknown[]) => {
|
|
||||||
const id = args[0] as string
|
|
||||||
console.log('Download started:', id)
|
|
||||||
updateDownload({ id, changes: { status: 'downloading' } })
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcEvents.on('download:progress', (...args: unknown[]) => {
|
|
||||||
const data = args[0] as { id: string; progress: unknown }
|
|
||||||
console.log('Download progress:', data)
|
|
||||||
const progress = data.progress as {
|
|
||||||
percent: number
|
|
||||||
currentSpeed?: string
|
|
||||||
eta?: string
|
|
||||||
downloaded?: string
|
|
||||||
total?: string
|
|
||||||
}
|
|
||||||
updateDownload({
|
|
||||||
id: data.id,
|
|
||||||
changes: {
|
|
||||||
progress: {
|
|
||||||
percent: progress.percent || 0,
|
|
||||||
currentSpeed: progress.currentSpeed || '',
|
|
||||||
eta: progress.eta || '',
|
|
||||||
downloaded: progress.downloaded || '',
|
|
||||||
total: progress.total || ''
|
|
||||||
},
|
|
||||||
speed: progress.currentSpeed || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcEvents.on('download:completed', (...args: unknown[]) => {
|
|
||||||
const id = args[0] as string
|
|
||||||
console.log('Download completed:', id)
|
|
||||||
updateDownload({ id, changes: { status: 'completed' } })
|
|
||||||
toast.success(t('notifications.downloadCompleted'))
|
|
||||||
void syncHistoryItem(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcEvents.on('download:error', (...args: unknown[]) => {
|
|
||||||
const data = args[0] as { id: string; error: string }
|
|
||||||
console.error('Download error:', data)
|
|
||||||
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
|
|
||||||
toast.error(t('notifications.downloadFailed'))
|
|
||||||
void syncHistoryItem(data.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
|
|
||||||
const id = args[0] as string
|
|
||||||
console.log('Download cancelled:', id)
|
|
||||||
updateDownload({ id, changes: { status: 'cancelled' } })
|
|
||||||
void syncHistoryItem(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Event listeners are automatically cleaned up when the component unmounts
|
|
||||||
}
|
|
||||||
}, [open, loadSettings, syncHistoryItem, t, updateDownload])
|
|
||||||
|
|
||||||
const startOneClickDownload = useCallback(
|
const startOneClickDownload = useCallback(
|
||||||
async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {
|
async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {
|
||||||
@@ -748,7 +665,6 @@ export function DownloadDialog({
|
|||||||
addDownload(downloadItem)
|
addDownload(downloadItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
|
|
||||||
setOpen(false) // Close dialog after download starts
|
setOpen(false) // Close dialog after download starts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start playlist download:', error)
|
console.error('Failed to start playlist download:', error)
|
||||||
@@ -831,7 +747,6 @@ export function DownloadDialog({
|
|||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success(t('notifications.downloadStarted'))
|
|
||||||
setOpen(false) // Close dialog after download starts
|
setOpen(false) // Close dialog after download starts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start download:', error)
|
console.error('Failed to start download:', error)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function PlaylistDownload({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 w-full rounded-md border">
|
<ScrollArea className="flex-1 w-full rounded-md border max-h-[45vh]">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
{playlistInfo.entries.map((entry) => {
|
{playlistInfo.entries.map((entry) => {
|
||||||
const isSelected = selectedEntryIds.has(entry.id)
|
const isSelected = selectedEntryIds.has(entry.id)
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ export function PlaylistDownloadGroup({
|
|||||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2 mx-6">
|
<div className="rounded-md bg-muted/30 mx-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="px-3 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"
|
||||||
onClick={() => setIsExpanded((prev) => !prev)}
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
@@ -118,7 +118,7 @@ export function PlaylistDownloadGroup({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1 pr-3">
|
||||||
{canDeletePlaylist && (
|
{canDeletePlaylist && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,17 +153,15 @@ export function PlaylistDownloadGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
<div className="space-y-3 pt-1">
|
{records.map((record) => (
|
||||||
{records.map((record) => (
|
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
<DownloadItem
|
||||||
<DownloadItem
|
download={record}
|
||||||
download={record}
|
isSelected={selectedIds?.has(record.id) ?? false}
|
||||||
isSelected={selectedIds?.has(record.id) ?? false}
|
onToggleSelect={onToggleSelect}
|
||||||
onToggleSelect={onToggleSelect}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ const getCodecShortName = (codec?: string): string => {
|
|||||||
return codec.split('.')[0].toUpperCase()
|
return codec.split('.')[0].toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHlsFormat = (format: VideoFormat): boolean =>
|
||||||
|
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||||
|
|
||||||
|
const isHttpProtocol = (format: VideoFormat): boolean =>
|
||||||
|
!!format.protocol && format.protocol.startsWith('http')
|
||||||
|
|
||||||
const filterFormatsByType = (
|
const filterFormatsByType = (
|
||||||
formats: VideoInfo['formats'],
|
formats: VideoInfo['formats'],
|
||||||
activeTab: 'video' | 'audio'
|
activeTab: 'video' | 'audio'
|
||||||
@@ -270,6 +276,30 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
|||||||
return `${mb.toFixed(2)} MB`
|
return `${mb.toFixed(2)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMetaLabel = (format: VideoFormat) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
const pushPart = (label: string, value?: string) => {
|
||||||
|
if (!value) return
|
||||||
|
parts.push(`${label}:${value}`)
|
||||||
|
}
|
||||||
|
pushPart('proto', format.protocol)
|
||||||
|
pushPart('lang', format.language?.trim())
|
||||||
|
if (format.tbr) {
|
||||||
|
pushPart('tbr', `${Math.round(format.tbr)}k`)
|
||||||
|
}
|
||||||
|
if (typeof format.quality === 'number') {
|
||||||
|
pushPart('q', String(format.quality))
|
||||||
|
}
|
||||||
|
if (format.vcodec && format.vcodec !== 'none') {
|
||||||
|
pushPart('vcodec', format.vcodec)
|
||||||
|
}
|
||||||
|
if (format.acodec && format.acodec !== 'none') {
|
||||||
|
pushPart('acodec', format.acodec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
const formatVideoQuality = (format: VideoFormat) => {
|
const formatVideoQuality = (format: VideoFormat) => {
|
||||||
if (format.height) {
|
if (format.height) {
|
||||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||||
@@ -339,6 +369,7 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
|||||||
? format.acodec.split('.')[0].toUpperCase()
|
? format.acodec.split('.')[0].toUpperCase()
|
||||||
: ''
|
: ''
|
||||||
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
|
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
|
||||||
|
const metaLabel = formatMetaLabel(format)
|
||||||
const isSelected = selectedFormat === format.format_id
|
const isSelected = selectedFormat === format.format_id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -363,12 +394,19 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
|||||||
{qualityLabel}
|
{qualityLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||||
{thirdColumnLabel}
|
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||||
</span>
|
{thirdColumnLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{metaLabel && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted-foreground/70 leading-snug break-words">
|
||||||
|
{metaLabel}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -401,7 +439,16 @@ export function SingleVideoDownload({
|
|||||||
|
|
||||||
const relevantFormats = useMemo(() => {
|
const relevantFormats = useMemo(() => {
|
||||||
if (!videoInfo?.formats) return []
|
if (!videoInfo?.formats) return []
|
||||||
return filterFormatsByType(videoInfo.formats, activeTab)
|
const baseFormats = filterFormatsByType(videoInfo.formats, activeTab)
|
||||||
|
if (baseFormats.length === 0) return []
|
||||||
|
|
||||||
|
const hasHttpFormats = baseFormats.some(isHttpProtocol)
|
||||||
|
if (!hasHttpFormats) {
|
||||||
|
return baseFormats
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format))
|
||||||
|
return nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats
|
||||||
}, [videoInfo?.formats, activeTab])
|
}, [videoInfo?.formats, activeTab])
|
||||||
|
|
||||||
const containers = useMemo(() => {
|
const containers = useMemo(() => {
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
|
|||||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!target || !(target instanceof HTMLElement)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const tagName = target.tagName
|
||||||
|
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
|
||||||
|
}
|
||||||
|
|
||||||
interface UnifiedDownloadHistoryProps {
|
interface UnifiedDownloadHistoryProps {
|
||||||
onOpenSupportedSites?: () => void
|
onOpenSupportedSites?: () => void
|
||||||
onOpenSettings?: () => void
|
onOpenSettings?: () => void
|
||||||
@@ -163,6 +174,12 @@ export function UnifiedDownloadHistory({
|
|||||||
})
|
})
|
||||||
}, [allRecords, statusFilter])
|
}, [allRecords, statusFilter])
|
||||||
|
|
||||||
|
const visibleHistoryIds = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||||
|
[filteredRecords]
|
||||||
|
)
|
||||||
|
|
||||||
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
|
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
|
||||||
{ key: 'all', label: t('download.all'), count: downloadStats.total },
|
{ key: 'all', label: t('download.all'), count: downloadStats.total },
|
||||||
{ key: 'active', label: t('download.active'), count: downloadStats.active },
|
{ key: 'active', label: t('download.active'), count: downloadStats.active },
|
||||||
@@ -170,16 +187,34 @@ export function UnifiedDownloadHistory({
|
|||||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectableIds = useMemo(
|
const selectableIds = useMemo(() => {
|
||||||
() =>
|
if (visibleHistoryIds.length === 0) {
|
||||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
return []
|
||||||
[filteredRecords]
|
}
|
||||||
)
|
const ids = new Set(visibleHistoryIds)
|
||||||
|
const playlistIds = new Set(
|
||||||
|
filteredRecords
|
||||||
|
.filter((record) => record.entryType === 'history' && record.playlistId)
|
||||||
|
.map((record) => record.playlistId as string)
|
||||||
|
)
|
||||||
|
if (playlistIds.size === 0) {
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
for (const record of historyRecords) {
|
||||||
|
if (record.playlistId && playlistIds.has(record.playlistId)) {
|
||||||
|
ids.add(record.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(ids)
|
||||||
|
}, [filteredRecords, historyRecords, visibleHistoryIds])
|
||||||
const selectableCount = selectableIds.length
|
const selectableCount = selectableIds.length
|
||||||
|
const visibleSelectableCount = visibleHistoryIds.length
|
||||||
const selectionSummary =
|
const selectionSummary =
|
||||||
selectableCount === 0
|
selectableCount === 0
|
||||||
? t('history.selectedCount', { count: selectedCount })
|
? t('history.selectedCount', { count: selectedCount })
|
||||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
: selectableCount > visibleSelectableCount
|
||||||
|
? t('history.selectedCount', { count: selectedCount })
|
||||||
|
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIds.size === 0) {
|
if (selectedIds.size === 0) {
|
||||||
@@ -216,6 +251,13 @@ export function UnifiedDownloadHistory({
|
|||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectableIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set(selectableIds))
|
||||||
|
}
|
||||||
|
|
||||||
const handleRequestDeleteSelected = () => {
|
const handleRequestDeleteSelected = () => {
|
||||||
if (selectedIds.size === 0) {
|
if (selectedIds.size === 0) {
|
||||||
return
|
return
|
||||||
@@ -398,6 +440,41 @@ export function UnifiedDownloadHistory({
|
|||||||
return { order, groups }
|
return { order, groups }
|
||||||
}, [filteredRecords])
|
}, [filteredRecords])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isEditableTarget(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (confirmAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(event.metaKey || event.ctrlKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() !== 'a') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectableIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIds(new Set(selectableIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [confirmAction, selectableIds, selectedIds])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col h-full')}>
|
<div className={cn('flex flex-col h-full')}>
|
||||||
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
|
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
|
||||||
@@ -431,6 +508,15 @@ export function UnifiedDownloadHistory({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full px-3"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={selectableIds.length === 0}
|
||||||
|
>
|
||||||
|
{t('history.selectAll')}
|
||||||
|
</Button>
|
||||||
<DownloadDialog
|
<DownloadDialog
|
||||||
onOpenSupportedSites={onOpenSupportedSites}
|
onOpenSupportedSites={onOpenSupportedSites}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ipcServices } from '@renderer/lib/ipc'
|
|||||||
import { Github, MessageCircle, Twitter } from 'lucide-react'
|
import { Github, MessageCircle, Twitter } from 'lucide-react'
|
||||||
import { type MouseEvent, useEffect, useMemo, useState } from 'react'
|
import { type MouseEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
type AppInfo = {
|
type AppInfo = {
|
||||||
appVersion: string
|
appVersion: string
|
||||||
@@ -11,12 +12,13 @@ type AppInfo = {
|
|||||||
|
|
||||||
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
|
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
|
||||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||||
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[bug]: Download error report'
|
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[Bug]: Download error report'
|
||||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||||
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
|
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
|
||||||
|
const FEEDBACK_MAX_GITHUB_URL_LENGTH = 7000
|
||||||
|
|
||||||
let cachedAppInfo: AppInfo | null = null
|
let cachedAppInfo: AppInfo | null = null
|
||||||
let appInfoPromise: Promise<AppInfo> | null = null
|
let appInfoPromise: Promise<AppInfo> | null = null
|
||||||
@@ -37,12 +39,12 @@ const buildIssueLogs = (
|
|||||||
): string => {
|
): string => {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
if (sourceUrl) {
|
if (sourceUrl) {
|
||||||
lines.push(`${urlLabel}: ${sourceUrl}`)
|
lines.push(`**${urlLabel}:**\n${sourceUrl}\n`)
|
||||||
}
|
}
|
||||||
if (ytDlpCommand) {
|
if (ytDlpCommand) {
|
||||||
lines.push(`${commandLabel}: ${ytDlpCommand}`)
|
lines.push(`**${commandLabel}:**\n\`\`\`bash\n${ytDlpCommand}\n\`\`\`\n`)
|
||||||
}
|
}
|
||||||
lines.push(`${errorLabel}: ${errorText}`)
|
lines.push(`**${errorLabel}:**\n${errorText}`)
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +108,13 @@ type FeedbackLinkButtonsProps = {
|
|||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
|
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
|
||||||
ytDlpCommand?: string
|
ytDlpCommand?: string
|
||||||
|
useSimpleGithubUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeedbackLinkButtons = ({
|
export const FeedbackLinkButtons = ({
|
||||||
error,
|
error,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
issueTitle = '[bug]: ',
|
issueTitle = '[Bug]: ',
|
||||||
includeAppInfo = false,
|
includeAppInfo = false,
|
||||||
appInfo,
|
appInfo,
|
||||||
buttonVariant = 'outline',
|
buttonVariant = 'outline',
|
||||||
@@ -119,7 +122,8 @@ export const FeedbackLinkButtons = ({
|
|||||||
buttonClassName,
|
buttonClassName,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
ytDlpCommand
|
ytDlpCommand,
|
||||||
|
useSimpleGithubUrl = false
|
||||||
}: FeedbackLinkButtonsProps) => {
|
}: FeedbackLinkButtonsProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const fallbackAppInfo = useAppInfo()
|
const fallbackAppInfo = useAppInfo()
|
||||||
@@ -138,43 +142,48 @@ export const FeedbackLinkButtons = ({
|
|||||||
const tweetText = encodeURIComponent(
|
const tweetText = encodeURIComponent(
|
||||||
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
|
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
|
||||||
)
|
)
|
||||||
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
const issueError = compactError || FEEDBACK_UNKNOWN_ERROR
|
||||||
const resolvedSourceUrl = sourceUrl?.trim() || undefined
|
const resolvedSourceUrl = sourceUrl?.trim() || undefined
|
||||||
const normalizedCommand = ytDlpCommand?.trim() || undefined
|
const normalizedCommand = ytDlpCommand?.trim() || undefined
|
||||||
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
|
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
|
||||||
const issueLogs = shouldIncludeLogs
|
const issueLogs = shouldIncludeLogs
|
||||||
? clampText(
|
? buildIssueLogs(
|
||||||
buildIssueLogs(
|
issueError,
|
||||||
issueError,
|
resolvedSourceUrl,
|
||||||
resolvedSourceUrl,
|
normalizedCommand,
|
||||||
normalizedCommand,
|
FEEDBACK_SOURCE_LABEL,
|
||||||
FEEDBACK_SOURCE_LABEL,
|
FEEDBACK_ERROR_LABEL,
|
||||||
FEEDBACK_ERROR_LABEL,
|
FEEDBACK_COMMAND_LABEL
|
||||||
FEEDBACK_COMMAND_LABEL
|
|
||||||
),
|
|
||||||
800
|
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
|
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
|
||||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||||
const issueParams = new URLSearchParams({
|
|
||||||
template: 'bug_report.yml',
|
|
||||||
title: issueTitle
|
|
||||||
})
|
|
||||||
|
|
||||||
if (issueLogs) {
|
let githubUrl: string
|
||||||
issueParams.set('logs', issueLogs)
|
if (useSimpleGithubUrl) {
|
||||||
}
|
githubUrl = 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||||
if (includeAppInfo) {
|
} else {
|
||||||
issueParams.set('app_version', appVersionValue)
|
const issueParams = new URLSearchParams({
|
||||||
issueParams.set('os_version', osVersionValue)
|
template: 'bug_report.yml',
|
||||||
|
title: issueTitle
|
||||||
|
})
|
||||||
|
|
||||||
|
if (issueLogs) {
|
||||||
|
issueParams.set('logs', issueLogs)
|
||||||
|
}
|
||||||
|
if (includeAppInfo) {
|
||||||
|
issueParams.set('app_version', appVersionValue)
|
||||||
|
issueParams.set('os_version', osVersionValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
githubUrl = `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
icon: Github,
|
icon: Github,
|
||||||
label: t('about.resources.githubIssues'),
|
label: t('about.resources.githubIssues'),
|
||||||
href: `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
|
href: githubUrl
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Twitter,
|
icon: Twitter,
|
||||||
@@ -187,7 +196,24 @@ export const FeedbackLinkButtons = ({
|
|||||||
href: 'https://discord.gg/uBqXV6QPdm'
|
href: 'https://discord.gg/uBqXV6QPdm'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}, [appVersion, error, includeAppInfo, issueTitle, osVersion, sourceUrl, t, ytDlpCommand])
|
}, [
|
||||||
|
appVersion,
|
||||||
|
error,
|
||||||
|
includeAppInfo,
|
||||||
|
issueTitle,
|
||||||
|
osVersion,
|
||||||
|
sourceUrl,
|
||||||
|
t,
|
||||||
|
ytDlpCommand,
|
||||||
|
useSimpleGithubUrl
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||||
|
if (href.startsWith('https://github.com') && href.length >= FEEDBACK_MAX_GITHUB_URL_LENGTH) {
|
||||||
|
toast.info(t('download.feedback.githubUrlTooLong'))
|
||||||
|
}
|
||||||
|
onLinkClick?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -201,7 +227,12 @@ export const FeedbackLinkButtons = ({
|
|||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a href={resource.href} target="_blank" rel="noreferrer" onClick={onLinkClick}>
|
<a
|
||||||
|
href={resource.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(event) => handleLinkClick(event, resource.href)}
|
||||||
|
>
|
||||||
<Icon className={iconClassName} />
|
<Icon className={iconClassName} />
|
||||||
{resource.label}
|
{resource.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function SubscriptionFormDialog({
|
|||||||
|
|
||||||
const handleOpenRSSHubDocs = async () => {
|
const handleOpenRSSHubDocs = async () => {
|
||||||
try {
|
try {
|
||||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open RSSHub documentation:', error)
|
console.error('Failed to open RSSHub documentation:', error)
|
||||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||||
@@ -242,7 +242,7 @@ export function SubscriptionFormDialog({
|
|||||||
<Input
|
<Input
|
||||||
id={urlInputId}
|
id={urlInputId}
|
||||||
value={url}
|
value={url}
|
||||||
placeholder={t('subscriptions.placeholders.url')}
|
placeholder="https://docs.rsshub.app/routes/youtube/user/@FKJ"
|
||||||
onChange={(event) => setUrl(event.target.value)}
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{detectingFeed && (
|
{detectingFeed && (
|
||||||
|
|||||||
155
src/renderer/src/hooks/use-download-events.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useSetAtom } from 'jotai'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||||
|
import {
|
||||||
|
addDownloadAtom,
|
||||||
|
addHistoryRecordAtom,
|
||||||
|
removeDownloadAtom,
|
||||||
|
updateDownloadAtom
|
||||||
|
} from '../store/downloads'
|
||||||
|
|
||||||
|
const isFinalStatus = (status?: string): boolean =>
|
||||||
|
status === 'completed' || status === 'error' || status === 'cancelled'
|
||||||
|
|
||||||
|
export function useDownloadEvents() {
|
||||||
|
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||||
|
const addDownload = useSetAtom(addDownloadAtom)
|
||||||
|
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
|
||||||
|
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const syncHistoryItem = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const historyItem = await ipcServices.history.getHistoryById(id)
|
||||||
|
if (!historyItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addHistoryRecord(historyItem)
|
||||||
|
if (isFinalStatus(historyItem.status)) {
|
||||||
|
removeDownload(id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync history item:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addHistoryRecord, removeDownload]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncActiveDownloads = async () => {
|
||||||
|
try {
|
||||||
|
const activeDownloads = await ipcServices.download.getActiveDownloads()
|
||||||
|
activeDownloads.forEach((item) => {
|
||||||
|
addDownload(item)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load active downloads:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncActiveDownloads()
|
||||||
|
}, [addDownload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStarted = (rawId: unknown) => {
|
||||||
|
const id = typeof rawId === 'string' ? rawId : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateDownload({
|
||||||
|
id,
|
||||||
|
changes: {
|
||||||
|
status: 'downloading',
|
||||||
|
startedAt: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProgress = (rawData: unknown) => {
|
||||||
|
const data = rawData as { id?: string; progress?: unknown }
|
||||||
|
const id = typeof data?.id === 'string' ? data.id : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const progress = (data.progress ?? {}) as {
|
||||||
|
percent?: number
|
||||||
|
currentSpeed?: string
|
||||||
|
eta?: string
|
||||||
|
downloaded?: string
|
||||||
|
total?: string
|
||||||
|
}
|
||||||
|
updateDownload({
|
||||||
|
id,
|
||||||
|
changes: {
|
||||||
|
progress: {
|
||||||
|
percent: typeof progress.percent === 'number' ? progress.percent : 0,
|
||||||
|
currentSpeed: progress.currentSpeed || '',
|
||||||
|
eta: progress.eta || '',
|
||||||
|
downloaded: progress.downloaded || '',
|
||||||
|
total: progress.total || ''
|
||||||
|
},
|
||||||
|
speed: progress.currentSpeed || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLog = (rawData: unknown) => {
|
||||||
|
const data = rawData as { id?: string; log?: string }
|
||||||
|
const id = typeof data?.id === 'string' ? data.id : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const logText = typeof data?.log === 'string' ? data.log : ''
|
||||||
|
updateDownload({ id, changes: { ytDlpLog: logText } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompleted = (rawId: unknown) => {
|
||||||
|
const id = typeof rawId === 'string' ? rawId : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateDownload({ id, changes: { status: 'completed', completedAt: Date.now() } })
|
||||||
|
toast.success(t('notifications.downloadCompleted'))
|
||||||
|
void syncHistoryItem(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (rawData: unknown) => {
|
||||||
|
const data = rawData as { id?: string; error?: string }
|
||||||
|
const id = typeof data?.id === 'string' ? data.id : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const errorMessage = typeof data?.error === 'string' ? data.error : ''
|
||||||
|
updateDownload({ id, changes: { status: 'error', error: errorMessage } })
|
||||||
|
toast.error(t('notifications.downloadFailed'))
|
||||||
|
void syncHistoryItem(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelled = (rawId: unknown) => {
|
||||||
|
const id = typeof rawId === 'string' ? rawId : ''
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateDownload({ id, changes: { status: 'cancelled', completedAt: Date.now() } })
|
||||||
|
void syncHistoryItem(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedSubscription = ipcEvents.on('download:started', handleStarted)
|
||||||
|
const progressSubscription = ipcEvents.on('download:progress', handleProgress)
|
||||||
|
const logSubscription = ipcEvents.on('download:log', handleLog)
|
||||||
|
const completedSubscription = ipcEvents.on('download:completed', handleCompleted)
|
||||||
|
const errorSubscription = ipcEvents.on('download:error', handleError)
|
||||||
|
const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled)
|
||||||
|
return () => {
|
||||||
|
ipcEvents.removeListener('download:started', startedSubscription)
|
||||||
|
ipcEvents.removeListener('download:progress', progressSubscription)
|
||||||
|
ipcEvents.removeListener('download:log', logSubscription)
|
||||||
|
ipcEvents.removeListener('download:completed', completedSubscription)
|
||||||
|
ipcEvents.removeListener('download:error', errorSubscription)
|
||||||
|
ipcEvents.removeListener('download:cancelled', cancelledSubscription)
|
||||||
|
}
|
||||||
|
}, [syncHistoryItem, t, updateDownload])
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "البحث عن التحديثات...",
|
"checkingUpdates": "البحث عن التحديثات...",
|
||||||
"downloadError": "فشل في تنزيل التحديث",
|
"downloadError": "فشل في تنزيل التحديث",
|
||||||
"downloadStarted": "بدأ التحميل...",
|
|
||||||
"downloadUpdate": "تنزيل وتثبيت التحديث {{version}}؟",
|
"downloadUpdate": "تنزيل وتثبيت التحديث {{version}}؟",
|
||||||
"manualDownloadAction": "تنزيل الآن",
|
"manualDownloadAction": "تنزيل الآن",
|
||||||
"noUpdatesAvailable": "أنت تستخدم أحدث إصدار",
|
"noUpdatesAvailable": "أنت تستخدم أحدث إصدار",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "قيد الانتظار",
|
"downloadPending": "قيد الانتظار",
|
||||||
"downloadQueue": "قائمة انتظار التحميل",
|
"downloadQueue": "قائمة انتظار التحميل",
|
||||||
"customDownloadFolder": "مجلد تنزيل مخصص",
|
"customDownloadFolder": "مجلد تنزيل مخصص",
|
||||||
|
"retry": "إعادة المحاولة",
|
||||||
"autoFolderPlaceholder": "مجلد تلقائي (استنادًا إلى البيانات الوصفية)",
|
"autoFolderPlaceholder": "مجلد تلقائي (استنادًا إلى البيانات الوصفية)",
|
||||||
"autoFolderHint": "يتم إنشاء المجلدات التلقائية من البيانات الوصفية.",
|
"autoFolderHint": "يتم إنشاء المجلدات التلقائية من البيانات الوصفية.",
|
||||||
"useAutoFolder": "استخدام المجلد التلقائي",
|
"useAutoFolder": "استخدام المجلد التلقائي",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "خطأ",
|
"error": "خطأ",
|
||||||
"fetch": "جلب",
|
"fetch": "جلب",
|
||||||
"fetchingVideoInfo": "جلب معلومات الفيديو...",
|
"fetchingVideoInfo": "جلب معلومات الفيديو...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "أبلغ عن هذا الخطأ:",
|
||||||
|
"githubUrlTooLong": "رابط GitHub هذا طويل جدًا. إذا لم يفتح، فالرجاء فتح صفحة المشكلة ولصق السجلات يدويًا."
|
||||||
|
},
|
||||||
"history": "السجل",
|
"history": "السجل",
|
||||||
"imageLoadError": "فشل تحميل الصورة",
|
"imageLoadError": "فشل تحميل الصورة",
|
||||||
"imagePlaceholder": "لا توجد صورة متاحة",
|
"imagePlaceholder": "لا توجد صورة متاحة",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "التقدم",
|
"progress": "التقدم",
|
||||||
"showDetails": "إظهار التفاصيل",
|
"showDetails": "إظهار التفاصيل",
|
||||||
"hideDetails": "إخفاء التفاصيل",
|
"hideDetails": "إخفاء التفاصيل",
|
||||||
|
"viewLogs": "عرض السجلات",
|
||||||
|
"detailsTab": "التفاصيل",
|
||||||
|
"logsTab": "السجلات",
|
||||||
|
"logs": {
|
||||||
|
"live": "سجلات مباشرة",
|
||||||
|
"history": "سجلات محفوظة",
|
||||||
|
"command": "أمر yt-dlp",
|
||||||
|
"empty": "لا توجد سجلات بعد.",
|
||||||
|
"scrollPaused": "تم إيقاف التمرير"
|
||||||
|
},
|
||||||
"selectAudioFormat": "اختر تنسيق الصوت",
|
"selectAudioFormat": "اختر تنسيق الصوت",
|
||||||
"selectDownloadType": "اختر نوع التنزيل",
|
"selectDownloadType": "اختر نوع التنزيل",
|
||||||
"selectFormat": "اختر التنسيق",
|
"selectFormat": "اختر التنسيق",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "ملاحظة التنسيق",
|
"formatNote": "ملاحظة التنسيق",
|
||||||
"protocol": "البروتوكول",
|
"protocol": "البروتوكول",
|
||||||
"subscription": "الاشتراك"
|
"subscription": "الاشتراك"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "انقر لفتح في المتصفح",
|
"openInBrowser": "انقر لفتح في المتصفح",
|
||||||
"removeAction": "إزالة",
|
"removeAction": "إزالة",
|
||||||
"removeItem": "إزالة العنصر",
|
"removeItem": "إزالة العنصر",
|
||||||
|
"deleteFile": "حذف الملف",
|
||||||
|
"deleteRecord": "إزالة من القائمة",
|
||||||
"select": "تحديد",
|
"select": "تحديد",
|
||||||
"selectAll": "تحديد الكل",
|
"selectAll": "تحديد الكل",
|
||||||
"selectVisible": "تحديد المرئي",
|
"selectVisible": "تحديد المرئي",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "فشل في النسخ إلى الحافظة",
|
"copyFailed": "فشل في النسخ إلى الحافظة",
|
||||||
"downloadCompleted": "اكتمل التحميل",
|
"downloadCompleted": "اكتمل التحميل",
|
||||||
"downloadFailed": "فشل التحميل",
|
"downloadFailed": "فشل التحميل",
|
||||||
"downloadStarted": "بدأ التحميل",
|
|
||||||
"historyCleared": "تم مسح السجل",
|
"historyCleared": "تم مسح السجل",
|
||||||
"historyClearFailed": "فشل مسح السجل",
|
"historyClearFailed": "فشل مسح السجل",
|
||||||
"itemRemoved": "تم إزالة العنصر",
|
"itemRemoved": "تم إزالة العنصر",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
|
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
|
||||||
"downloadFailed": "فشل في بدء تحميل قائمة التشغيل",
|
"downloadFailed": "فشل في بدء تحميل قائمة التشغيل",
|
||||||
"downloadPlaylist": "تحميل قائمة التشغيل",
|
"downloadPlaylist": "تحميل قائمة التشغيل",
|
||||||
"downloadStarted": "بدأ تحميل {{count}} فيديو من قائمة التشغيل",
|
|
||||||
"downloadType": "نوع التحميل",
|
"downloadType": "نوع التحميل",
|
||||||
"downloading": "جاري تحميل قائمة التشغيل:",
|
"downloading": "جاري تحميل قائمة التشغيل:",
|
||||||
"endIndex": "النهاية",
|
"endIndex": "النهاية",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "تفضيلات الصوت",
|
"audio": "تفضيلات الصوت",
|
||||||
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
|
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
|
||||||
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
|
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
|
||||||
|
"browserForCookiesWindowsNote": "في Windows، يتم دعم ملفات تعريف الارتباط من Firefox فقط. للمتصفحات الأخرى، يرجى إعداد ملف ملفات تعريف الارتباط يدويًا.",
|
||||||
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
|
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
|
||||||
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
|
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
|
||||||
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
|
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "معطل",
|
"disabled": "معطل",
|
||||||
"onlyLatestShort": "الأحدث فقط"
|
"onlyLatestShort": "الأحدث فقط"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "إضافة",
|
"add": "إضافة",
|
||||||
"refresh": "تحديث",
|
"refresh": "تحديث",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
|
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
|
||||||
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
|
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
|
||||||
"openLinkError": "فشل في فتح رابط الفيديو.",
|
"openLinkError": "فشل في فتح رابط الفيديو.",
|
||||||
"resolveError": "فشل في حل رابط تغذية RSS."
|
"resolveError": "فشل في حل رابط تغذية RSS.",
|
||||||
|
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
|
||||||
},
|
},
|
||||||
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
|
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
|
||||||
"detecting": "جاري اكتشاف التغذية...",
|
"detecting": "جاري اكتشاف التغذية...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Suche nach Updates...",
|
"checkingUpdates": "Suche nach Updates...",
|
||||||
"downloadError": "Update konnte nicht heruntergeladen werden",
|
"downloadError": "Update konnte nicht heruntergeladen werden",
|
||||||
"downloadStarted": "Download gestartet...",
|
|
||||||
"downloadUpdate": "Update {{version}} herunterladen und installieren?",
|
"downloadUpdate": "Update {{version}} herunterladen und installieren?",
|
||||||
"manualDownloadAction": "Jetzt herunterladen",
|
"manualDownloadAction": "Jetzt herunterladen",
|
||||||
"noUpdatesAvailable": "Sie verwenden die neueste Version",
|
"noUpdatesAvailable": "Sie verwenden die neueste Version",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Ausstehend",
|
"downloadPending": "Ausstehend",
|
||||||
"downloadQueue": "Download-Warteschlange",
|
"downloadQueue": "Download-Warteschlange",
|
||||||
"customDownloadFolder": "Benutzerdefinierter Download-Ordner",
|
"customDownloadFolder": "Benutzerdefinierter Download-Ordner",
|
||||||
|
"retry": "Download erneut versuchen",
|
||||||
"autoFolderPlaceholder": "Automatischer Ordner (basierend auf Metadaten)",
|
"autoFolderPlaceholder": "Automatischer Ordner (basierend auf Metadaten)",
|
||||||
"autoFolderHint": "Automatische Ordner werden aus Metadaten erstellt.",
|
"autoFolderHint": "Automatische Ordner werden aus Metadaten erstellt.",
|
||||||
"useAutoFolder": "Automatischen Ordner verwenden",
|
"useAutoFolder": "Automatischen Ordner verwenden",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"fetch": "Abrufen",
|
"fetch": "Abrufen",
|
||||||
"fetchingVideoInfo": "Video-Informationen werden abgerufen...",
|
"fetchingVideoInfo": "Video-Informationen werden abgerufen...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Fehler melden:",
|
||||||
|
"githubUrlTooLong": "Dieser GitHub-Link ist sehr lang. Wenn er sich nicht öffnet, öffnen Sie bitte die Issue-Seite und fügen Sie die Logs manuell ein."
|
||||||
|
},
|
||||||
"history": "Verlauf",
|
"history": "Verlauf",
|
||||||
"imageLoadError": "Bild konnte nicht geladen werden",
|
"imageLoadError": "Bild konnte nicht geladen werden",
|
||||||
"imagePlaceholder": "Kein Bild verfügbar",
|
"imagePlaceholder": "Kein Bild verfügbar",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Fortschritt",
|
"progress": "Fortschritt",
|
||||||
"showDetails": "Details anzeigen",
|
"showDetails": "Details anzeigen",
|
||||||
"hideDetails": "Details ausblenden",
|
"hideDetails": "Details ausblenden",
|
||||||
|
"viewLogs": "Logs anzeigen",
|
||||||
|
"detailsTab": "Details",
|
||||||
|
"logsTab": "Logs",
|
||||||
|
"logs": {
|
||||||
|
"live": "Live-Logs",
|
||||||
|
"history": "Gespeicherte Logs",
|
||||||
|
"command": "yt-dlp-Befehl",
|
||||||
|
"empty": "Noch keine Logs.",
|
||||||
|
"scrollPaused": "Scrollen pausiert"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Audio-Format auswählen",
|
"selectAudioFormat": "Audio-Format auswählen",
|
||||||
"selectDownloadType": "Download-Typ auswählen",
|
"selectDownloadType": "Download-Typ auswählen",
|
||||||
"selectFormat": "Format auswählen",
|
"selectFormat": "Format auswählen",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Format-Hinweis",
|
"formatNote": "Format-Hinweis",
|
||||||
"protocol": "Protokoll",
|
"protocol": "Protokoll",
|
||||||
"subscription": "Abonnement"
|
"subscription": "Abonnement"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Klicken, um im Browser zu öffnen",
|
"openInBrowser": "Klicken, um im Browser zu öffnen",
|
||||||
"removeAction": "Entfernen",
|
"removeAction": "Entfernen",
|
||||||
"removeItem": "Element entfernen",
|
"removeItem": "Element entfernen",
|
||||||
|
"deleteFile": "Datei löschen",
|
||||||
|
"deleteRecord": "Aus Liste entfernen",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"selectAll": "Alle auswählen",
|
"selectAll": "Alle auswählen",
|
||||||
"selectVisible": "Sichtbare auswählen",
|
"selectVisible": "Sichtbare auswählen",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Kopieren in Zwischenablage fehlgeschlagen",
|
"copyFailed": "Kopieren in Zwischenablage fehlgeschlagen",
|
||||||
"downloadCompleted": "Download abgeschlossen",
|
"downloadCompleted": "Download abgeschlossen",
|
||||||
"downloadFailed": "Download fehlgeschlagen",
|
"downloadFailed": "Download fehlgeschlagen",
|
||||||
"downloadStarted": "Download gestartet",
|
|
||||||
"historyCleared": "Verlauf gelöscht",
|
"historyCleared": "Verlauf gelöscht",
|
||||||
"historyClearFailed": "Verlauf konnte nicht gelöscht werden",
|
"historyClearFailed": "Verlauf konnte nicht gelöscht werden",
|
||||||
"itemRemoved": "Element entfernt",
|
"itemRemoved": "Element entfernt",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen",
|
"description": "Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen",
|
||||||
"downloadFailed": "Playlist-Download konnte nicht gestartet werden",
|
"downloadFailed": "Playlist-Download konnte nicht gestartet werden",
|
||||||
"downloadPlaylist": "Playlist herunterladen",
|
"downloadPlaylist": "Playlist herunterladen",
|
||||||
"downloadStarted": "Download von {{count}} Videos aus Playlist gestartet",
|
|
||||||
"downloadType": "Download-Typ",
|
"downloadType": "Download-Typ",
|
||||||
"downloading": "Playlist wird heruntergeladen:",
|
"downloading": "Playlist wird heruntergeladen:",
|
||||||
"endIndex": "Ende",
|
"endIndex": "Ende",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Audio-Einstellungen",
|
"audio": "Audio-Einstellungen",
|
||||||
"browserForCookies": "Browser für Cookies auswählen",
|
"browserForCookies": "Browser für Cookies auswählen",
|
||||||
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
|
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
|
||||||
|
"browserForCookiesWindowsNote": "Unter Windows werden nur Firefox-Cookies unterstützt. Für andere Browser richten Sie bitte manuell eine Cookie-Datei ein.",
|
||||||
"browserForCookiesProfile": "Profilname oder Pfad",
|
"browserForCookiesProfile": "Profilname oder Pfad",
|
||||||
"browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.",
|
"browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.",
|
||||||
"browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)",
|
"browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Deaktiviert",
|
"disabled": "Deaktiviert",
|
||||||
"onlyLatestShort": "Nur neueste"
|
"onlyLatestShort": "Nur neueste"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
|
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
|
||||||
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
|
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
|
||||||
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
|
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
|
||||||
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden."
|
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden.",
|
||||||
|
"duplicateUrl": "Dieser RSS-Feed ist bereits abonniert."
|
||||||
},
|
},
|
||||||
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
|
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
|
||||||
"detecting": "Feed wird erkannt...",
|
"detecting": "Feed wird erkannt...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Looking for updates...",
|
"checkingUpdates": "Looking for updates...",
|
||||||
"downloadError": "Failed to download update",
|
"downloadError": "Failed to download update",
|
||||||
"downloadStarted": "Download started...",
|
|
||||||
"downloadUpdate": "Download and install update {{version}}?",
|
"downloadUpdate": "Download and install update {{version}}?",
|
||||||
"manualDownloadAction": "Download now",
|
"manualDownloadAction": "Download now",
|
||||||
"noUpdatesAvailable": "You're using the latest version",
|
"noUpdatesAvailable": "You're using the latest version",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Pending",
|
"downloadPending": "Pending",
|
||||||
"downloadQueue": "Download Queue",
|
"downloadQueue": "Download Queue",
|
||||||
"customDownloadFolder": "Custom download folder",
|
"customDownloadFolder": "Custom download folder",
|
||||||
|
"retry": "Retry Download",
|
||||||
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
|
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
|
||||||
"autoFolderHint": "Automatic folders are created from metadata.",
|
"autoFolderHint": "Automatic folders are created from metadata.",
|
||||||
"useAutoFolder": "Use automatic folder",
|
"useAutoFolder": "Use automatic folder",
|
||||||
@@ -131,7 +131,8 @@
|
|||||||
"fetch": "Fetch",
|
"fetch": "Fetch",
|
||||||
"fetchingVideoInfo": "Fetching video info...",
|
"fetchingVideoInfo": "Fetching video info...",
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"title": "Report this error:"
|
"title": "Report this error:",
|
||||||
|
"githubUrlTooLong": "This GitHub link is very long. If it fails to open, please open the issue page and paste the logs manually."
|
||||||
},
|
},
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"imageLoadError": "Image failed to load",
|
"imageLoadError": "Image failed to load",
|
||||||
@@ -158,6 +159,16 @@
|
|||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"showDetails": "Show details",
|
"showDetails": "Show details",
|
||||||
"hideDetails": "Hide details",
|
"hideDetails": "Hide details",
|
||||||
|
"viewLogs": "View logs",
|
||||||
|
"detailsTab": "Details",
|
||||||
|
"logsTab": "Logs",
|
||||||
|
"logs": {
|
||||||
|
"live": "Live logs",
|
||||||
|
"history": "Saved logs",
|
||||||
|
"command": "yt-dlp command",
|
||||||
|
"empty": "No logs yet.",
|
||||||
|
"scrollPaused": "Scroll paused"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Select Audio Format",
|
"selectAudioFormat": "Select Audio Format",
|
||||||
"selectDownloadType": "Select download type",
|
"selectDownloadType": "Select download type",
|
||||||
"selectFormat": "Select Format",
|
"selectFormat": "Select Format",
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Click to open in browser",
|
"openInBrowser": "Click to open in browser",
|
||||||
"removeAction": "Remove",
|
"removeAction": "Remove",
|
||||||
"removeItem": "Remove Item",
|
"removeItem": "Remove Item",
|
||||||
|
"deleteFile": "Delete File",
|
||||||
|
"deleteRecord": "Remove from List",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
"selectVisible": "Select visible",
|
"selectVisible": "Select visible",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Failed to copy to clipboard",
|
"copyFailed": "Failed to copy to clipboard",
|
||||||
"downloadCompleted": "Download completed",
|
"downloadCompleted": "Download completed",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
"downloadStarted": "Download started",
|
|
||||||
"historyCleared": "History cleared",
|
"historyCleared": "History cleared",
|
||||||
"historyClearFailed": "Failed to clear history",
|
"historyClearFailed": "Failed to clear history",
|
||||||
"itemRemoved": "Item removed",
|
"itemRemoved": "Item removed",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Download all videos from a YouTube playlist or channel",
|
"description": "Download all videos from a YouTube playlist or channel",
|
||||||
"downloadFailed": "Failed to start playlist download",
|
"downloadFailed": "Failed to start playlist download",
|
||||||
"downloadPlaylist": "Download Playlist",
|
"downloadPlaylist": "Download Playlist",
|
||||||
"downloadStarted": "Started downloading {{count}} videos from playlist",
|
|
||||||
"downloadType": "Download Type",
|
"downloadType": "Download Type",
|
||||||
"downloading": "Downloading playlist:",
|
"downloading": "Downloading playlist:",
|
||||||
"endIndex": "End",
|
"endIndex": "End",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Audio Preferences",
|
"audio": "Audio Preferences",
|
||||||
"browserForCookies": "Select browser to use cookies from",
|
"browserForCookies": "Select browser to use cookies from",
|
||||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
|
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
|
||||||
|
"browserForCookiesWindowsNote": "Windows only supports Firefox cookies. For other browsers, please configure a cookies file manually.",
|
||||||
"browserForCookiesProfile": "Profile name or path",
|
"browserForCookiesProfile": "Profile name or path",
|
||||||
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
|
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
|
||||||
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
|
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"onlyLatestShort": "Only latest"
|
"onlyLatestShort": "Only latest"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
@@ -538,6 +547,7 @@
|
|||||||
"missingUrl": "Please paste a channel link first.",
|
"missingUrl": "Please paste a channel link first.",
|
||||||
"created": "Subscription added",
|
"created": "Subscription added",
|
||||||
"createError": "Failed to add subscription.",
|
"createError": "Failed to add subscription.",
|
||||||
|
"duplicateUrl": "This RSS feed is already subscribed.",
|
||||||
"refreshStarted": "Refresh started",
|
"refreshStarted": "Refresh started",
|
||||||
"removed": "Subscription removed",
|
"removed": "Subscription removed",
|
||||||
"updated": "Subscription updated",
|
"updated": "Subscription updated",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Buscando actualizaciones...",
|
"checkingUpdates": "Buscando actualizaciones...",
|
||||||
"downloadError": "Error al descargar la actualización",
|
"downloadError": "Error al descargar la actualización",
|
||||||
"downloadStarted": "Descarga iniciada...",
|
|
||||||
"downloadUpdate": "¿Descargar e instalar la actualización {{version}}?",
|
"downloadUpdate": "¿Descargar e instalar la actualización {{version}}?",
|
||||||
"manualDownloadAction": "Descargar ahora",
|
"manualDownloadAction": "Descargar ahora",
|
||||||
"noUpdatesAvailable": "Estás usando la última versión",
|
"noUpdatesAvailable": "Estás usando la última versión",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Pendiente",
|
"downloadPending": "Pendiente",
|
||||||
"downloadQueue": "Cola de Descarga",
|
"downloadQueue": "Cola de Descarga",
|
||||||
"customDownloadFolder": "Carpeta de descarga personalizada",
|
"customDownloadFolder": "Carpeta de descarga personalizada",
|
||||||
|
"retry": "Reintentar descarga",
|
||||||
"autoFolderPlaceholder": "Carpeta automática (basada en metadatos)",
|
"autoFolderPlaceholder": "Carpeta automática (basada en metadatos)",
|
||||||
"autoFolderHint": "Las carpetas automáticas se crean a partir de metadatos.",
|
"autoFolderHint": "Las carpetas automáticas se crean a partir de metadatos.",
|
||||||
"useAutoFolder": "Usar carpeta automática",
|
"useAutoFolder": "Usar carpeta automática",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"fetch": "Obtener",
|
"fetch": "Obtener",
|
||||||
"fetchingVideoInfo": "Obteniendo información del video...",
|
"fetchingVideoInfo": "Obteniendo información del video...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Informe este error:",
|
||||||
|
"githubUrlTooLong": "Este enlace de GitHub es muy largo. Si no se abre, abre la página de la incidencia y pega los registros manualmente."
|
||||||
|
},
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"imageLoadError": "Error al cargar la imagen",
|
"imageLoadError": "Error al cargar la imagen",
|
||||||
"imagePlaceholder": "No hay imagen disponible",
|
"imagePlaceholder": "No hay imagen disponible",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Progreso",
|
"progress": "Progreso",
|
||||||
"showDetails": "Mostrar detalles",
|
"showDetails": "Mostrar detalles",
|
||||||
"hideDetails": "Ocultar detalles",
|
"hideDetails": "Ocultar detalles",
|
||||||
|
"viewLogs": "Ver registros",
|
||||||
|
"detailsTab": "Detalles",
|
||||||
|
"logsTab": "Registros",
|
||||||
|
"logs": {
|
||||||
|
"live": "Registros en vivo",
|
||||||
|
"history": "Registros guardados",
|
||||||
|
"command": "Comando de yt-dlp",
|
||||||
|
"empty": "Aún no hay registros.",
|
||||||
|
"scrollPaused": "Desplazamiento en pausa"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Seleccionar Formato de Audio",
|
"selectAudioFormat": "Seleccionar Formato de Audio",
|
||||||
"selectDownloadType": "Seleccionar tipo de descarga",
|
"selectDownloadType": "Seleccionar tipo de descarga",
|
||||||
"selectFormat": "Seleccionar Formato",
|
"selectFormat": "Seleccionar Formato",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Nota de formato",
|
"formatNote": "Nota de formato",
|
||||||
"protocol": "Protocolo",
|
"protocol": "Protocolo",
|
||||||
"subscription": "Suscripción"
|
"subscription": "Suscripción"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Haz clic para abrir en el navegador",
|
"openInBrowser": "Haz clic para abrir en el navegador",
|
||||||
"removeAction": "Eliminar",
|
"removeAction": "Eliminar",
|
||||||
"removeItem": "Eliminar Elemento",
|
"removeItem": "Eliminar Elemento",
|
||||||
|
"deleteFile": "Eliminar Archivo",
|
||||||
|
"deleteRecord": "Eliminar de la Lista",
|
||||||
"select": "Seleccionar",
|
"select": "Seleccionar",
|
||||||
"selectAll": "Seleccionar todo",
|
"selectAll": "Seleccionar todo",
|
||||||
"selectVisible": "Seleccionar visibles",
|
"selectVisible": "Seleccionar visibles",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Error al copiar al portapapeles",
|
"copyFailed": "Error al copiar al portapapeles",
|
||||||
"downloadCompleted": "Descarga completada",
|
"downloadCompleted": "Descarga completada",
|
||||||
"downloadFailed": "Error en la descarga",
|
"downloadFailed": "Error en la descarga",
|
||||||
"downloadStarted": "Descarga iniciada",
|
|
||||||
"historyCleared": "Historial borrado",
|
"historyCleared": "Historial borrado",
|
||||||
"historyClearFailed": "No se pudo borrar el historial",
|
"historyClearFailed": "No se pudo borrar el historial",
|
||||||
"itemRemoved": "Elemento eliminado",
|
"itemRemoved": "Elemento eliminado",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Descarga todos los videos de una lista de reproducción o canal de YouTube",
|
"description": "Descarga todos los videos de una lista de reproducción o canal de YouTube",
|
||||||
"downloadFailed": "Error al iniciar la descarga de la lista de reproducción",
|
"downloadFailed": "Error al iniciar la descarga de la lista de reproducción",
|
||||||
"downloadPlaylist": "Descargar Lista de Reproducción",
|
"downloadPlaylist": "Descargar Lista de Reproducción",
|
||||||
"downloadStarted": "Comenzó la descarga de {{count}} videos de la lista de reproducción",
|
|
||||||
"downloadType": "Tipo de Descarga",
|
"downloadType": "Tipo de Descarga",
|
||||||
"downloading": "Descargando lista de reproducción:",
|
"downloading": "Descargando lista de reproducción:",
|
||||||
"endIndex": "Fin",
|
"endIndex": "Fin",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Preferencias de Audio",
|
"audio": "Preferencias de Audio",
|
||||||
"browserForCookies": "Seleccionar navegador para usar cookies",
|
"browserForCookies": "Seleccionar navegador para usar cookies",
|
||||||
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
|
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
|
||||||
|
"browserForCookiesWindowsNote": "Windows solo admite cookies de Firefox. Para otros navegadores, configura manualmente un archivo de cookies.",
|
||||||
"browserForCookiesProfile": "Nombre de perfil o ruta",
|
"browserForCookiesProfile": "Nombre de perfil o ruta",
|
||||||
"browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.",
|
"browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.",
|
||||||
"browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)",
|
"browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Deshabilitado",
|
"disabled": "Deshabilitado",
|
||||||
"onlyLatestShort": "Solo último"
|
"onlyLatestShort": "Solo último"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Agregar",
|
"add": "Agregar",
|
||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Este video ya está en cola",
|
"itemAlreadyQueued": "Este video ya está en cola",
|
||||||
"queueError": "Error al agregar a la cola de descarga.",
|
"queueError": "Error al agregar a la cola de descarga.",
|
||||||
"openLinkError": "Error al abrir el enlace del video.",
|
"openLinkError": "Error al abrir el enlace del video.",
|
||||||
"resolveError": "Error al resolver la URL del feed RSS."
|
"resolveError": "Error al resolver la URL del feed RSS.",
|
||||||
|
"duplicateUrl": "Este feed RSS ya está suscrito."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
|
"detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
|
||||||
"detecting": "Detectando feed...",
|
"detecting": "Detectando feed...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Recherche de mises à jour...",
|
"checkingUpdates": "Recherche de mises à jour...",
|
||||||
"downloadError": "Échec du téléchargement de la mise à jour",
|
"downloadError": "Échec du téléchargement de la mise à jour",
|
||||||
"downloadStarted": "Téléchargement démarré...",
|
|
||||||
"downloadUpdate": "Télécharger et installer la mise à jour {{version}} ?",
|
"downloadUpdate": "Télécharger et installer la mise à jour {{version}} ?",
|
||||||
"manualDownloadAction": "Téléchargez maintenant",
|
"manualDownloadAction": "Téléchargez maintenant",
|
||||||
"noUpdatesAvailable": "Vous utilisez la dernière version",
|
"noUpdatesAvailable": "Vous utilisez la dernière version",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "En attente",
|
"downloadPending": "En attente",
|
||||||
"downloadQueue": "File de Téléchargement",
|
"downloadQueue": "File de Téléchargement",
|
||||||
"customDownloadFolder": "Dossier de téléchargement personnalisé",
|
"customDownloadFolder": "Dossier de téléchargement personnalisé",
|
||||||
|
"retry": "Relancer le téléchargement",
|
||||||
"autoFolderPlaceholder": "Dossier automatique (basé sur les métadonnées)",
|
"autoFolderPlaceholder": "Dossier automatique (basé sur les métadonnées)",
|
||||||
"autoFolderHint": "Les dossiers automatiques sont créés à partir des métadonnées.",
|
"autoFolderHint": "Les dossiers automatiques sont créés à partir des métadonnées.",
|
||||||
"useAutoFolder": "Utiliser le dossier automatique",
|
"useAutoFolder": "Utiliser le dossier automatique",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"fetch": "Récupérer",
|
"fetch": "Récupérer",
|
||||||
"fetchingVideoInfo": "Récupération des informations vidéo...",
|
"fetchingVideoInfo": "Récupération des informations vidéo...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Signalez cette erreur :",
|
||||||
|
"githubUrlTooLong": "Ce lien GitHub est très long. S'il ne s'ouvre pas, ouvrez la page de l'issue et collez les logs manuellement."
|
||||||
|
},
|
||||||
"history": "Historique",
|
"history": "Historique",
|
||||||
"imageLoadError": "Échec du chargement de l'image",
|
"imageLoadError": "Échec du chargement de l'image",
|
||||||
"imagePlaceholder": "Aucune image disponible",
|
"imagePlaceholder": "Aucune image disponible",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Progrès",
|
"progress": "Progrès",
|
||||||
"showDetails": "Afficher les détails",
|
"showDetails": "Afficher les détails",
|
||||||
"hideDetails": "Masquer les détails",
|
"hideDetails": "Masquer les détails",
|
||||||
|
"viewLogs": "Voir les journaux",
|
||||||
|
"detailsTab": "Détails",
|
||||||
|
"logsTab": "Journaux",
|
||||||
|
"logs": {
|
||||||
|
"live": "Journaux en direct",
|
||||||
|
"history": "Journaux enregistrés",
|
||||||
|
"command": "Commande yt-dlp",
|
||||||
|
"empty": "Aucun journal pour le moment.",
|
||||||
|
"scrollPaused": "Défilement en pause"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||||
"selectDownloadType": "Sélectionner le type de téléchargement",
|
"selectDownloadType": "Sélectionner le type de téléchargement",
|
||||||
"selectFormat": "Sélectionner le Format",
|
"selectFormat": "Sélectionner le Format",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Remarque sur le format",
|
"formatNote": "Remarque sur le format",
|
||||||
"protocol": "Protocole",
|
"protocol": "Protocole",
|
||||||
"subscription": "Abonnement"
|
"subscription": "Abonnement"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
|
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
|
||||||
"removeAction": "Supprimer",
|
"removeAction": "Supprimer",
|
||||||
"removeItem": "Supprimer l'Élément",
|
"removeItem": "Supprimer l'Élément",
|
||||||
|
"deleteFile": "Supprimer le Fichier",
|
||||||
|
"deleteRecord": "Supprimer de la Liste",
|
||||||
"select": "Sélectionner",
|
"select": "Sélectionner",
|
||||||
"selectAll": "Tout sélectionner",
|
"selectAll": "Tout sélectionner",
|
||||||
"selectVisible": "Sélectionner les visibles",
|
"selectVisible": "Sélectionner les visibles",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Échec de la copie dans le presse-papiers",
|
"copyFailed": "Échec de la copie dans le presse-papiers",
|
||||||
"downloadCompleted": "Téléchargement terminé",
|
"downloadCompleted": "Téléchargement terminé",
|
||||||
"downloadFailed": "Échec du téléchargement",
|
"downloadFailed": "Échec du téléchargement",
|
||||||
"downloadStarted": "Téléchargement démarré",
|
|
||||||
"historyCleared": "Historique effacé",
|
"historyCleared": "Historique effacé",
|
||||||
"historyClearFailed": "Échec de l'effacement de l'historique",
|
"historyClearFailed": "Échec de l'effacement de l'historique",
|
||||||
"itemRemoved": "Élément supprimé",
|
"itemRemoved": "Élément supprimé",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Télécharger toutes les vidéos d'une playlist ou chaîne YouTube",
|
"description": "Télécharger toutes les vidéos d'une playlist ou chaîne YouTube",
|
||||||
"downloadFailed": "Échec du démarrage du téléchargement de playlist",
|
"downloadFailed": "Échec du démarrage du téléchargement de playlist",
|
||||||
"downloadPlaylist": "Télécharger la Playlist",
|
"downloadPlaylist": "Télécharger la Playlist",
|
||||||
"downloadStarted": "Démarré le téléchargement de {{count}} vidéos de la playlist",
|
|
||||||
"downloadType": "Type de Téléchargement",
|
"downloadType": "Type de Téléchargement",
|
||||||
"downloading": "Téléchargement de la playlist :",
|
"downloading": "Téléchargement de la playlist :",
|
||||||
"endIndex": "Fin",
|
"endIndex": "Fin",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Préférences Audio",
|
"audio": "Préférences Audio",
|
||||||
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
||||||
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
||||||
|
"browserForCookiesWindowsNote": "Sous Windows, seuls les cookies de Firefox sont pris en charge. Pour les autres navigateurs, configurez un fichier de cookies manuellement.",
|
||||||
"browserForCookiesProfile": "Nom du profil ou chemin",
|
"browserForCookiesProfile": "Nom du profil ou chemin",
|
||||||
"browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.",
|
"browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.",
|
||||||
"browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)",
|
"browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Désactivé",
|
"disabled": "Désactivé",
|
||||||
"onlyLatestShort": "Seulement le dernier"
|
"onlyLatestShort": "Seulement le dernier"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"refresh": "Rafraîchir",
|
"refresh": "Rafraîchir",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
|
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
|
||||||
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
|
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
|
||||||
"openLinkError": "Échec de l'ouverture du lien vidéo.",
|
"openLinkError": "Échec de l'ouverture du lien vidéo.",
|
||||||
"resolveError": "Échec de la résolution de l'URL du flux RSS."
|
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
|
||||||
|
"duplicateUrl": "Ce flux RSS est déjà abonné."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
|
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
|
||||||
"detecting": "Détection du flux...",
|
"detecting": "Détection du flux...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Mencari pembaruan...",
|
"checkingUpdates": "Mencari pembaruan...",
|
||||||
"downloadError": "Gagal mengunduh pembaruan",
|
"downloadError": "Gagal mengunduh pembaruan",
|
||||||
"downloadStarted": "Unduhan dimulai...",
|
|
||||||
"downloadUpdate": "Unduh dan instal pembaruan {{version}}?",
|
"downloadUpdate": "Unduh dan instal pembaruan {{version}}?",
|
||||||
"manualDownloadAction": "Unduh sekarang",
|
"manualDownloadAction": "Unduh sekarang",
|
||||||
"noUpdatesAvailable": "Anda menggunakan versi terbaru",
|
"noUpdatesAvailable": "Anda menggunakan versi terbaru",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Menunggu",
|
"downloadPending": "Menunggu",
|
||||||
"downloadQueue": "Antrian Unduhan",
|
"downloadQueue": "Antrian Unduhan",
|
||||||
"customDownloadFolder": "Folder unduhan khusus",
|
"customDownloadFolder": "Folder unduhan khusus",
|
||||||
|
"retry": "Coba ulang unduhan",
|
||||||
"autoFolderPlaceholder": "Folder otomatis (berdasarkan metadata)",
|
"autoFolderPlaceholder": "Folder otomatis (berdasarkan metadata)",
|
||||||
"autoFolderHint": "Folder otomatis dibuat dari metadata.",
|
"autoFolderHint": "Folder otomatis dibuat dari metadata.",
|
||||||
"useAutoFolder": "Gunakan folder otomatis",
|
"useAutoFolder": "Gunakan folder otomatis",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Kesalahan",
|
"error": "Kesalahan",
|
||||||
"fetch": "Ambil",
|
"fetch": "Ambil",
|
||||||
"fetchingVideoInfo": "Mengambil info video...",
|
"fetchingVideoInfo": "Mengambil info video...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Laporkan kesalahan ini:",
|
||||||
|
"githubUrlTooLong": "Tautan GitHub ini sangat panjang. Jika tidak terbuka, buka halaman issue dan tempel log secara manual."
|
||||||
|
},
|
||||||
"history": "Riwayat",
|
"history": "Riwayat",
|
||||||
"imageLoadError": "Gagal memuat gambar",
|
"imageLoadError": "Gagal memuat gambar",
|
||||||
"imagePlaceholder": "Tidak ada gambar tersedia",
|
"imagePlaceholder": "Tidak ada gambar tersedia",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Kemajuan",
|
"progress": "Kemajuan",
|
||||||
"showDetails": "Tampilkan detail",
|
"showDetails": "Tampilkan detail",
|
||||||
"hideDetails": "Sembunyikan detail",
|
"hideDetails": "Sembunyikan detail",
|
||||||
|
"viewLogs": "Lihat log",
|
||||||
|
"detailsTab": "Detail",
|
||||||
|
"logsTab": "Log",
|
||||||
|
"logs": {
|
||||||
|
"live": "Log langsung",
|
||||||
|
"history": "Log tersimpan",
|
||||||
|
"command": "Perintah yt-dlp",
|
||||||
|
"empty": "Belum ada log.",
|
||||||
|
"scrollPaused": "Pengguliran dijeda"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Pilih Format Audio",
|
"selectAudioFormat": "Pilih Format Audio",
|
||||||
"selectDownloadType": "Pilih jenis unduhan",
|
"selectDownloadType": "Pilih jenis unduhan",
|
||||||
"selectFormat": "Pilih Format",
|
"selectFormat": "Pilih Format",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Catatan format",
|
"formatNote": "Catatan format",
|
||||||
"protocol": "Protokol",
|
"protocol": "Protokol",
|
||||||
"subscription": "Berlangganan"
|
"subscription": "Berlangganan"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Klik untuk membuka di browser",
|
"openInBrowser": "Klik untuk membuka di browser",
|
||||||
"removeAction": "Hapus",
|
"removeAction": "Hapus",
|
||||||
"removeItem": "Hapus Item",
|
"removeItem": "Hapus Item",
|
||||||
|
"deleteFile": "Hapus File",
|
||||||
|
"deleteRecord": "Hapus dari Daftar",
|
||||||
"select": "Pilih",
|
"select": "Pilih",
|
||||||
"selectAll": "Pilih semua",
|
"selectAll": "Pilih semua",
|
||||||
"selectVisible": "Pilih yang terlihat",
|
"selectVisible": "Pilih yang terlihat",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Gagal menyalin ke clipboard",
|
"copyFailed": "Gagal menyalin ke clipboard",
|
||||||
"downloadCompleted": "Unduhan selesai",
|
"downloadCompleted": "Unduhan selesai",
|
||||||
"downloadFailed": "Unduhan gagal",
|
"downloadFailed": "Unduhan gagal",
|
||||||
"downloadStarted": "Unduhan dimulai",
|
|
||||||
"historyCleared": "Riwayat dihapus",
|
"historyCleared": "Riwayat dihapus",
|
||||||
"historyClearFailed": "Gagal menghapus riwayat",
|
"historyClearFailed": "Gagal menghapus riwayat",
|
||||||
"itemRemoved": "Item dihapus",
|
"itemRemoved": "Item dihapus",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Unduh semua video dari playlist atau saluran YouTube",
|
"description": "Unduh semua video dari playlist atau saluran YouTube",
|
||||||
"downloadFailed": "Gagal memulai unduhan playlist",
|
"downloadFailed": "Gagal memulai unduhan playlist",
|
||||||
"downloadPlaylist": "Unduh Playlist",
|
"downloadPlaylist": "Unduh Playlist",
|
||||||
"downloadStarted": "Mulai mengunduh {{count}} video dari playlist",
|
|
||||||
"downloadType": "Jenis Unduhan",
|
"downloadType": "Jenis Unduhan",
|
||||||
"downloading": "Mengunduh playlist:",
|
"downloading": "Mengunduh playlist:",
|
||||||
"endIndex": "Akhir",
|
"endIndex": "Akhir",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Preferensi Audio",
|
"audio": "Preferensi Audio",
|
||||||
"browserForCookies": "Pilih browser untuk menggunakan cookie",
|
"browserForCookies": "Pilih browser untuk menggunakan cookie",
|
||||||
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
|
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
|
||||||
|
"browserForCookiesWindowsNote": "Di Windows, hanya cookie Firefox yang didukung. Untuk browser lain, silakan konfigurasi file cookie secara manual.",
|
||||||
"browserForCookiesProfile": "Nama profil atau path",
|
"browserForCookiesProfile": "Nama profil atau path",
|
||||||
"browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.",
|
"browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.",
|
||||||
"browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)",
|
"browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Dinonaktifkan",
|
"disabled": "Dinonaktifkan",
|
||||||
"onlyLatestShort": "Hanya terbaru"
|
"onlyLatestShort": "Hanya terbaru"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Tambah",
|
"add": "Tambah",
|
||||||
"refresh": "Segarkan",
|
"refresh": "Segarkan",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Video ini sudah diantre",
|
"itemAlreadyQueued": "Video ini sudah diantre",
|
||||||
"queueError": "Gagal menambahkan ke antrian unduhan.",
|
"queueError": "Gagal menambahkan ke antrian unduhan.",
|
||||||
"openLinkError": "Gagal membuka tautan video.",
|
"openLinkError": "Gagal membuka tautan video.",
|
||||||
"resolveError": "Gagal menyelesaikan URL feed RSS."
|
"resolveError": "Gagal menyelesaikan URL feed RSS.",
|
||||||
|
"duplicateUrl": "Feed RSS ini sudah berlangganan."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
|
"detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
|
||||||
"detecting": "Mendeteksi feed...",
|
"detecting": "Mendeteksi feed...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Ricerca aggiornamenti...",
|
"checkingUpdates": "Ricerca aggiornamenti...",
|
||||||
"downloadError": "Errore nel download dell'aggiornamento",
|
"downloadError": "Errore nel download dell'aggiornamento",
|
||||||
"downloadStarted": "Download iniziato...",
|
|
||||||
"downloadUpdate": "Scarica e installa l'aggiornamento {{version}}?",
|
"downloadUpdate": "Scarica e installa l'aggiornamento {{version}}?",
|
||||||
"manualDownloadAction": "Scarica ora",
|
"manualDownloadAction": "Scarica ora",
|
||||||
"noUpdatesAvailable": "Stai usando l'ultima versione",
|
"noUpdatesAvailable": "Stai usando l'ultima versione",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "In attesa",
|
"downloadPending": "In attesa",
|
||||||
"downloadQueue": "Coda Download",
|
"downloadQueue": "Coda Download",
|
||||||
"customDownloadFolder": "Cartella di download personalizzata",
|
"customDownloadFolder": "Cartella di download personalizzata",
|
||||||
|
"retry": "Riprova download",
|
||||||
"autoFolderPlaceholder": "Cartella automatica (in base ai metadati)",
|
"autoFolderPlaceholder": "Cartella automatica (in base ai metadati)",
|
||||||
"autoFolderHint": "Le cartelle automatiche vengono create dai metadati.",
|
"autoFolderHint": "Le cartelle automatiche vengono create dai metadati.",
|
||||||
"useAutoFolder": "Usa cartella automatica",
|
"useAutoFolder": "Usa cartella automatica",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Errore",
|
"error": "Errore",
|
||||||
"fetch": "Recupera",
|
"fetch": "Recupera",
|
||||||
"fetchingVideoInfo": "Recupero informazioni video...",
|
"fetchingVideoInfo": "Recupero informazioni video...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Segnala questo errore:",
|
||||||
|
"githubUrlTooLong": "Questo link GitHub è molto lungo. Se non si apre, apri la pagina dell'issue e incolla i log manualmente."
|
||||||
|
},
|
||||||
"history": "Cronologia",
|
"history": "Cronologia",
|
||||||
"imageLoadError": "Errore nel caricamento dell'immagine",
|
"imageLoadError": "Errore nel caricamento dell'immagine",
|
||||||
"imagePlaceholder": "Nessuna immagine disponibile",
|
"imagePlaceholder": "Nessuna immagine disponibile",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Progresso",
|
"progress": "Progresso",
|
||||||
"showDetails": "Mostra dettagli",
|
"showDetails": "Mostra dettagli",
|
||||||
"hideDetails": "Nascondi dettagli",
|
"hideDetails": "Nascondi dettagli",
|
||||||
|
"viewLogs": "Visualizza log",
|
||||||
|
"detailsTab": "Dettagli",
|
||||||
|
"logsTab": "Log",
|
||||||
|
"logs": {
|
||||||
|
"live": "Log in tempo reale",
|
||||||
|
"history": "Log salvati",
|
||||||
|
"command": "Comando yt-dlp",
|
||||||
|
"empty": "Nessun log ancora.",
|
||||||
|
"scrollPaused": "Scorrimento in pausa"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Seleziona Formato Audio",
|
"selectAudioFormat": "Seleziona Formato Audio",
|
||||||
"selectDownloadType": "Seleziona tipo di download",
|
"selectDownloadType": "Seleziona tipo di download",
|
||||||
"selectFormat": "Seleziona Formato",
|
"selectFormat": "Seleziona Formato",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Nota sul formato",
|
"formatNote": "Nota sul formato",
|
||||||
"protocol": "Protocollo",
|
"protocol": "Protocollo",
|
||||||
"subscription": "Sottoscrizione"
|
"subscription": "Sottoscrizione"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Clicca per aprire nel browser",
|
"openInBrowser": "Clicca per aprire nel browser",
|
||||||
"removeAction": "Rimuovi",
|
"removeAction": "Rimuovi",
|
||||||
"removeItem": "Rimuovi Elemento",
|
"removeItem": "Rimuovi Elemento",
|
||||||
|
"deleteFile": "Elimina File",
|
||||||
|
"deleteRecord": "Rimuovi dall'Elenco",
|
||||||
"select": "Seleziona",
|
"select": "Seleziona",
|
||||||
"selectAll": "Seleziona tutto",
|
"selectAll": "Seleziona tutto",
|
||||||
"selectVisible": "Seleziona visibili",
|
"selectVisible": "Seleziona visibili",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Errore nella copia negli appunti",
|
"copyFailed": "Errore nella copia negli appunti",
|
||||||
"downloadCompleted": "Download completato",
|
"downloadCompleted": "Download completato",
|
||||||
"downloadFailed": "Download fallito",
|
"downloadFailed": "Download fallito",
|
||||||
"downloadStarted": "Download iniziato",
|
|
||||||
"historyCleared": "Cronologia cancellata",
|
"historyCleared": "Cronologia cancellata",
|
||||||
"historyClearFailed": "Impossibile cancellare la cronologia",
|
"historyClearFailed": "Impossibile cancellare la cronologia",
|
||||||
"itemRemoved": "Elemento rimosso",
|
"itemRemoved": "Elemento rimosso",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Scarica tutti i video da una playlist o canale YouTube",
|
"description": "Scarica tutti i video da una playlist o canale YouTube",
|
||||||
"downloadFailed": "Errore nell'avvio del download playlist",
|
"downloadFailed": "Errore nell'avvio del download playlist",
|
||||||
"downloadPlaylist": "Scarica Playlist",
|
"downloadPlaylist": "Scarica Playlist",
|
||||||
"downloadStarted": "Iniziato download di {{count}} video dalla playlist",
|
|
||||||
"downloadType": "Tipo Download",
|
"downloadType": "Tipo Download",
|
||||||
"downloading": "Scaricando playlist:",
|
"downloading": "Scaricando playlist:",
|
||||||
"endIndex": "Fine",
|
"endIndex": "Fine",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Preferenze Audio",
|
"audio": "Preferenze Audio",
|
||||||
"browserForCookies": "Seleziona browser per usare i cookie",
|
"browserForCookies": "Seleziona browser per usare i cookie",
|
||||||
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
||||||
|
"browserForCookiesWindowsNote": "Su Windows sono supportati solo i cookie di Firefox. Per altri browser, configura manualmente un file di cookie.",
|
||||||
"browserForCookiesProfile": "Nome profilo o percorso",
|
"browserForCookiesProfile": "Nome profilo o percorso",
|
||||||
"browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.",
|
"browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.",
|
||||||
"browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)",
|
"browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Disabilitato",
|
"disabled": "Disabilitato",
|
||||||
"onlyLatestShort": "Solo più recente"
|
"onlyLatestShort": "Solo più recente"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Aggiungere",
|
"add": "Aggiungere",
|
||||||
"refresh": "Aggiorna",
|
"refresh": "Aggiorna",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Questo video è già in coda",
|
"itemAlreadyQueued": "Questo video è già in coda",
|
||||||
"queueError": "Impossibile aggiungere alla coda di download.",
|
"queueError": "Impossibile aggiungere alla coda di download.",
|
||||||
"openLinkError": "Impossibile aprire il collegamento video.",
|
"openLinkError": "Impossibile aprire il collegamento video.",
|
||||||
"resolveError": "Impossibile risolvere l'URL del feed RSS."
|
"resolveError": "Impossibile risolvere l'URL del feed RSS.",
|
||||||
|
"duplicateUrl": "Questo feed RSS è già sottoscritto."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
|
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
|
||||||
"detecting": "Rilevamento alimentazione...",
|
"detecting": "Rilevamento alimentazione...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "アップデートを検索中...",
|
"checkingUpdates": "アップデートを検索中...",
|
||||||
"downloadError": "アップデートのダウンロードに失敗",
|
"downloadError": "アップデートのダウンロードに失敗",
|
||||||
"downloadStarted": "ダウンロード開始...",
|
|
||||||
"downloadUpdate": "アップデート{{version}}をダウンロードしてインストールしますか?",
|
"downloadUpdate": "アップデート{{version}}をダウンロードしてインストールしますか?",
|
||||||
"manualDownloadAction": "今すぐダウンロード",
|
"manualDownloadAction": "今すぐダウンロード",
|
||||||
"noUpdatesAvailable": "最新バージョンを使用しています",
|
"noUpdatesAvailable": "最新バージョンを使用しています",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "保留中",
|
"downloadPending": "保留中",
|
||||||
"downloadQueue": "ダウンロードキュー",
|
"downloadQueue": "ダウンロードキュー",
|
||||||
"customDownloadFolder": "カスタムダウンロードフォルダー",
|
"customDownloadFolder": "カスタムダウンロードフォルダー",
|
||||||
|
"retry": "ダウンロードを再試行",
|
||||||
"autoFolderPlaceholder": "自動フォルダー(メタデータに基づく)",
|
"autoFolderPlaceholder": "自動フォルダー(メタデータに基づく)",
|
||||||
"autoFolderHint": "自動フォルダーはメタデータから作成されます。",
|
"autoFolderHint": "自動フォルダーはメタデータから作成されます。",
|
||||||
"useAutoFolder": "自動フォルダーを使用",
|
"useAutoFolder": "自動フォルダーを使用",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "エラー",
|
"error": "エラー",
|
||||||
"fetch": "取得",
|
"fetch": "取得",
|
||||||
"fetchingVideoInfo": "ビデオ情報を取得中...",
|
"fetchingVideoInfo": "ビデオ情報を取得中...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "このエラーを報告:",
|
||||||
|
"githubUrlTooLong": "このGitHubリンクは非常に長いです。開けない場合は、issueページを開いてログを手動で貼り付けてください。"
|
||||||
|
},
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
"imageLoadError": "画像の読み込みに失敗",
|
"imageLoadError": "画像の読み込みに失敗",
|
||||||
"imagePlaceholder": "利用可能な画像なし",
|
"imagePlaceholder": "利用可能な画像なし",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "進行状況",
|
"progress": "進行状況",
|
||||||
"showDetails": "詳細を表示",
|
"showDetails": "詳細を表示",
|
||||||
"hideDetails": "詳細を隠す",
|
"hideDetails": "詳細を隠す",
|
||||||
|
"viewLogs": "ログを表示",
|
||||||
|
"detailsTab": "詳細",
|
||||||
|
"logsTab": "ログ",
|
||||||
|
"logs": {
|
||||||
|
"live": "ライブログ",
|
||||||
|
"history": "保存済みログ",
|
||||||
|
"command": "yt-dlp コマンド",
|
||||||
|
"empty": "ログはまだありません。",
|
||||||
|
"scrollPaused": "スクロールを一時停止"
|
||||||
|
},
|
||||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||||
"selectDownloadType": "ダウンロードの種類を選択",
|
"selectDownloadType": "ダウンロードの種類を選択",
|
||||||
"selectFormat": "フォーマットを選択",
|
"selectFormat": "フォーマットを選択",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "メモのフォーマット",
|
"formatNote": "メモのフォーマット",
|
||||||
"protocol": "プロトコル",
|
"protocol": "プロトコル",
|
||||||
"subscription": "サブスクリプション"
|
"subscription": "サブスクリプション"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "ブラウザで開くにはクリック",
|
"openInBrowser": "ブラウザで開くにはクリック",
|
||||||
"removeAction": "削除",
|
"removeAction": "削除",
|
||||||
"removeItem": "アイテムを削除",
|
"removeItem": "アイテムを削除",
|
||||||
|
"deleteFile": "ファイルを削除",
|
||||||
|
"deleteRecord": "リストから削除",
|
||||||
"select": "選択",
|
"select": "選択",
|
||||||
"selectAll": "すべて選択",
|
"selectAll": "すべて選択",
|
||||||
"selectVisible": "表示中を選択",
|
"selectVisible": "表示中を選択",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "クリップボードへのコピーに失敗",
|
"copyFailed": "クリップボードへのコピーに失敗",
|
||||||
"downloadCompleted": "ダウンロード完了",
|
"downloadCompleted": "ダウンロード完了",
|
||||||
"downloadFailed": "ダウンロードに失敗",
|
"downloadFailed": "ダウンロードに失敗",
|
||||||
"downloadStarted": "ダウンロード開始",
|
|
||||||
"historyCleared": "履歴をクリアしました",
|
"historyCleared": "履歴をクリアしました",
|
||||||
"historyClearFailed": "履歴のクリアに失敗しました",
|
"historyClearFailed": "履歴のクリアに失敗しました",
|
||||||
"itemRemoved": "アイテムが削除されました",
|
"itemRemoved": "アイテムが削除されました",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
||||||
"downloadFailed": "プレイリストダウンロードの開始に失敗",
|
"downloadFailed": "プレイリストダウンロードの開始に失敗",
|
||||||
"downloadPlaylist": "プレイリストをダウンロード",
|
"downloadPlaylist": "プレイリストをダウンロード",
|
||||||
"downloadStarted": "プレイリストから{{count}}個のビデオのダウンロードを開始",
|
|
||||||
"downloadType": "ダウンロードタイプ",
|
"downloadType": "ダウンロードタイプ",
|
||||||
"downloading": "プレイリストをダウンロード中:",
|
"downloading": "プレイリストをダウンロード中:",
|
||||||
"endIndex": "終了",
|
"endIndex": "終了",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "オーディオ設定",
|
"audio": "オーディオ設定",
|
||||||
"browserForCookies": "Cookieに使用するブラウザを選択",
|
"browserForCookies": "Cookieに使用するブラウザを選択",
|
||||||
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
|
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
|
||||||
|
"browserForCookiesWindowsNote": "Windows では Firefox の Cookie のみ対応しています。他のブラウザは Cookie ファイルを手動で設定してください。",
|
||||||
"browserForCookiesProfile": "プロファイル名またはパス",
|
"browserForCookiesProfile": "プロファイル名またはパス",
|
||||||
"browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。",
|
"browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。",
|
||||||
"browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)",
|
"browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "無効",
|
"disabled": "無効",
|
||||||
"onlyLatestShort": "最新のもののみ"
|
"onlyLatestShort": "最新のもののみ"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"refresh": "リフレッシュ",
|
"refresh": "リフレッシュ",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
|
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
|
||||||
"queueError": "ダウンロードキューへの追加に失敗しました。",
|
"queueError": "ダウンロードキューへの追加に失敗しました。",
|
||||||
"openLinkError": "ビデオリンクを開けませんでした。",
|
"openLinkError": "ビデオリンクを開けませんでした。",
|
||||||
"resolveError": "RSS フィード URL を解決できませんでした。"
|
"resolveError": "RSS フィード URL を解決できませんでした。",
|
||||||
|
"duplicateUrl": "このRSSフィードはすでに登録されています。"
|
||||||
},
|
},
|
||||||
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
|
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
|
||||||
"detecting": "フィードを検出中...",
|
"detecting": "フィードを検出中...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "업데이트 검색 중...",
|
"checkingUpdates": "업데이트 검색 중...",
|
||||||
"downloadError": "업데이트 다운로드 실패",
|
"downloadError": "업데이트 다운로드 실패",
|
||||||
"downloadStarted": "다운로드 시작...",
|
|
||||||
"downloadUpdate": "업데이트 {{version}}을(를) 다운로드하고 설치하시겠습니까?",
|
"downloadUpdate": "업데이트 {{version}}을(를) 다운로드하고 설치하시겠습니까?",
|
||||||
"manualDownloadAction": "지금 다운로드",
|
"manualDownloadAction": "지금 다운로드",
|
||||||
"noUpdatesAvailable": "최신 버전을 사용 중입니다",
|
"noUpdatesAvailable": "최신 버전을 사용 중입니다",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "대기 중",
|
"downloadPending": "대기 중",
|
||||||
"downloadQueue": "다운로드 큐",
|
"downloadQueue": "다운로드 큐",
|
||||||
"customDownloadFolder": "사용자 지정 다운로드 폴더",
|
"customDownloadFolder": "사용자 지정 다운로드 폴더",
|
||||||
|
"retry": "다운로드 재시도",
|
||||||
"autoFolderPlaceholder": "자동 폴더(메타데이터 기반)",
|
"autoFolderPlaceholder": "자동 폴더(메타데이터 기반)",
|
||||||
"autoFolderHint": "자동 폴더는 메타데이터에서 생성됩니다.",
|
"autoFolderHint": "자동 폴더는 메타데이터에서 생성됩니다.",
|
||||||
"useAutoFolder": "자동 폴더 사용",
|
"useAutoFolder": "자동 폴더 사용",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "오류",
|
"error": "오류",
|
||||||
"fetch": "가져오기",
|
"fetch": "가져오기",
|
||||||
"fetchingVideoInfo": "비디오 정보 가져오는 중...",
|
"fetchingVideoInfo": "비디오 정보 가져오는 중...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "이 오류를 보고:",
|
||||||
|
"githubUrlTooLong": "이 GitHub 링크가 매우 깁니다. 열리지 않으면 이슈 페이지를 열고 로그를 수동으로 붙여 넣어 주세요."
|
||||||
|
},
|
||||||
"history": "기록",
|
"history": "기록",
|
||||||
"imageLoadError": "이미지 로드 실패",
|
"imageLoadError": "이미지 로드 실패",
|
||||||
"imagePlaceholder": "사용 가능한 이미지 없음",
|
"imagePlaceholder": "사용 가능한 이미지 없음",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "진행률",
|
"progress": "진행률",
|
||||||
"showDetails": "세부정보 표시",
|
"showDetails": "세부정보 표시",
|
||||||
"hideDetails": "세부정보 숨기기",
|
"hideDetails": "세부정보 숨기기",
|
||||||
|
"viewLogs": "로그 보기",
|
||||||
|
"detailsTab": "세부정보",
|
||||||
|
"logsTab": "로그",
|
||||||
|
"logs": {
|
||||||
|
"live": "실시간 로그",
|
||||||
|
"history": "저장된 로그",
|
||||||
|
"command": "yt-dlp 명령어",
|
||||||
|
"empty": "아직 로그가 없습니다.",
|
||||||
|
"scrollPaused": "스크롤 일시 중지"
|
||||||
|
},
|
||||||
"selectAudioFormat": "오디오 형식 선택",
|
"selectAudioFormat": "오디오 형식 선택",
|
||||||
"selectDownloadType": "다운로드 유형 선택",
|
"selectDownloadType": "다운로드 유형 선택",
|
||||||
"selectFormat": "형식 선택",
|
"selectFormat": "형식 선택",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "메모 형식",
|
"formatNote": "메모 형식",
|
||||||
"protocol": "규약",
|
"protocol": "규약",
|
||||||
"subscription": "신청"
|
"subscription": "신청"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "브라우저에서 열려면 클릭",
|
"openInBrowser": "브라우저에서 열려면 클릭",
|
||||||
"removeAction": "제거",
|
"removeAction": "제거",
|
||||||
"removeItem": "항목 제거",
|
"removeItem": "항목 제거",
|
||||||
|
"deleteFile": "파일 삭제",
|
||||||
|
"deleteRecord": "목록에서 제거",
|
||||||
"select": "선택",
|
"select": "선택",
|
||||||
"selectAll": "모두 선택",
|
"selectAll": "모두 선택",
|
||||||
"selectVisible": "표시된 항목 선택",
|
"selectVisible": "표시된 항목 선택",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "클립보드에 복사 실패",
|
"copyFailed": "클립보드에 복사 실패",
|
||||||
"downloadCompleted": "다운로드 완료",
|
"downloadCompleted": "다운로드 완료",
|
||||||
"downloadFailed": "다운로드 실패",
|
"downloadFailed": "다운로드 실패",
|
||||||
"downloadStarted": "다운로드 시작됨",
|
|
||||||
"historyCleared": "기록이 지워졌습니다",
|
"historyCleared": "기록이 지워졌습니다",
|
||||||
"historyClearFailed": "기록을 지우지 못했습니다",
|
"historyClearFailed": "기록을 지우지 못했습니다",
|
||||||
"itemRemoved": "항목 제거됨",
|
"itemRemoved": "항목 제거됨",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
||||||
"downloadFailed": "재생목록 다운로드 시작 실패",
|
"downloadFailed": "재생목록 다운로드 시작 실패",
|
||||||
"downloadPlaylist": "재생목록 다운로드",
|
"downloadPlaylist": "재생목록 다운로드",
|
||||||
"downloadStarted": "재생목록에서 {{count}}개 비디오 다운로드 시작됨",
|
|
||||||
"downloadType": "다운로드 유형",
|
"downloadType": "다운로드 유형",
|
||||||
"downloading": "재생목록 다운로드 중:",
|
"downloading": "재생목록 다운로드 중:",
|
||||||
"endIndex": "끝",
|
"endIndex": "끝",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "오디오 환경설정",
|
"audio": "오디오 환경설정",
|
||||||
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
||||||
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
||||||
|
"browserForCookiesWindowsNote": "Windows에서는 Firefox 쿠키만 지원됩니다. 다른 브라우저는 쿠키 파일을 수동으로 설정하세요.",
|
||||||
"browserForCookiesProfile": "프로필 이름 또는 경로",
|
"browserForCookiesProfile": "프로필 이름 또는 경로",
|
||||||
"browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.",
|
"browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.",
|
||||||
"browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)",
|
"browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "장애가 있는",
|
"disabled": "장애가 있는",
|
||||||
"onlyLatestShort": "최신만"
|
"onlyLatestShort": "최신만"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "추가하다",
|
"add": "추가하다",
|
||||||
"refresh": "새로 고치다",
|
"refresh": "새로 고치다",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
|
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
|
||||||
"queueError": "다운로드 대기열에 추가하지 못했습니다.",
|
"queueError": "다운로드 대기열에 추가하지 못했습니다.",
|
||||||
"openLinkError": "동영상 링크를 열지 못했습니다.",
|
"openLinkError": "동영상 링크를 열지 못했습니다.",
|
||||||
"resolveError": "RSS 피드 URL을 확인하지 못했습니다."
|
"resolveError": "RSS 피드 URL을 확인하지 못했습니다.",
|
||||||
|
"duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다."
|
||||||
},
|
},
|
||||||
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
|
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
|
||||||
"detecting": "피드 감지 중...",
|
"detecting": "피드 감지 중...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Procurando atualizações...",
|
"checkingUpdates": "Procurando atualizações...",
|
||||||
"downloadError": "Falha ao baixar atualização",
|
"downloadError": "Falha ao baixar atualização",
|
||||||
"downloadStarted": "Download iniciado...",
|
|
||||||
"downloadUpdate": "Baixar e instalar atualização {{version}}?",
|
"downloadUpdate": "Baixar e instalar atualização {{version}}?",
|
||||||
"manualDownloadAction": "Baixe agora",
|
"manualDownloadAction": "Baixe agora",
|
||||||
"noUpdatesAvailable": "Você está usando a versão mais recente",
|
"noUpdatesAvailable": "Você está usando a versão mais recente",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Pendente",
|
"downloadPending": "Pendente",
|
||||||
"downloadQueue": "Fila de Download",
|
"downloadQueue": "Fila de Download",
|
||||||
"customDownloadFolder": "Pasta de download personalizada",
|
"customDownloadFolder": "Pasta de download personalizada",
|
||||||
|
"retry": "Tentar baixar novamente",
|
||||||
"autoFolderPlaceholder": "Pasta automática (com base nos metadados)",
|
"autoFolderPlaceholder": "Pasta automática (com base nos metadados)",
|
||||||
"autoFolderHint": "Pastas automáticas são criadas a partir de metadados.",
|
"autoFolderHint": "Pastas automáticas são criadas a partir de metadados.",
|
||||||
"useAutoFolder": "Usar pasta automática",
|
"useAutoFolder": "Usar pasta automática",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Erro",
|
"error": "Erro",
|
||||||
"fetch": "Buscar",
|
"fetch": "Buscar",
|
||||||
"fetchingVideoInfo": "Buscando informações do vídeo...",
|
"fetchingVideoInfo": "Buscando informações do vídeo...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Relatar este erro:",
|
||||||
|
"githubUrlTooLong": "Este link do GitHub é muito longo. Se não abrir, abra a página da issue e cole os logs manualmente."
|
||||||
|
},
|
||||||
"history": "Histórico",
|
"history": "Histórico",
|
||||||
"imageLoadError": "Falha ao carregar imagem",
|
"imageLoadError": "Falha ao carregar imagem",
|
||||||
"imagePlaceholder": "Nenhuma imagem disponível",
|
"imagePlaceholder": "Nenhuma imagem disponível",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Progresso",
|
"progress": "Progresso",
|
||||||
"showDetails": "Mostrar detalhes",
|
"showDetails": "Mostrar detalhes",
|
||||||
"hideDetails": "Ocultar detalhes",
|
"hideDetails": "Ocultar detalhes",
|
||||||
|
"viewLogs": "Ver logs",
|
||||||
|
"detailsTab": "Detalhes",
|
||||||
|
"logsTab": "Logs",
|
||||||
|
"logs": {
|
||||||
|
"live": "Logs ao vivo",
|
||||||
|
"history": "Logs salvos",
|
||||||
|
"command": "Comando do yt-dlp",
|
||||||
|
"empty": "Ainda não há logs.",
|
||||||
|
"scrollPaused": "Rolagem pausada"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||||
"selectDownloadType": "Selecionar tipo de download",
|
"selectDownloadType": "Selecionar tipo de download",
|
||||||
"selectFormat": "Selecionar Formato",
|
"selectFormat": "Selecionar Formato",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Formatar nota",
|
"formatNote": "Formatar nota",
|
||||||
"protocol": "Protocolo",
|
"protocol": "Protocolo",
|
||||||
"subscription": "Subscrição"
|
"subscription": "Subscrição"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Clique para abrir no navegador",
|
"openInBrowser": "Clique para abrir no navegador",
|
||||||
"removeAction": "Remover",
|
"removeAction": "Remover",
|
||||||
"removeItem": "Remover Item",
|
"removeItem": "Remover Item",
|
||||||
|
"deleteFile": "Remover Arquivo",
|
||||||
|
"deleteRecord": "Remover da Lista",
|
||||||
"select": "Selecionar",
|
"select": "Selecionar",
|
||||||
"selectAll": "Selecionar tudo",
|
"selectAll": "Selecionar tudo",
|
||||||
"selectVisible": "Selecionar visíveis",
|
"selectVisible": "Selecionar visíveis",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Falha ao copiar para área de transferência",
|
"copyFailed": "Falha ao copiar para área de transferência",
|
||||||
"downloadCompleted": "Download concluído",
|
"downloadCompleted": "Download concluído",
|
||||||
"downloadFailed": "Download falhou",
|
"downloadFailed": "Download falhou",
|
||||||
"downloadStarted": "Download iniciado",
|
|
||||||
"historyCleared": "Histórico limpo",
|
"historyCleared": "Histórico limpo",
|
||||||
"historyClearFailed": "Falha ao limpar histórico",
|
"historyClearFailed": "Falha ao limpar histórico",
|
||||||
"itemRemoved": "Item removido",
|
"itemRemoved": "Item removido",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
|
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
|
||||||
"downloadFailed": "Falha ao iniciar download da playlist",
|
"downloadFailed": "Falha ao iniciar download da playlist",
|
||||||
"downloadPlaylist": "Baixar Playlist",
|
"downloadPlaylist": "Baixar Playlist",
|
||||||
"downloadStarted": "Iniciado download de {{count}} vídeos da playlist",
|
|
||||||
"downloadType": "Tipo de Download",
|
"downloadType": "Tipo de Download",
|
||||||
"downloading": "Baixando playlist:",
|
"downloading": "Baixando playlist:",
|
||||||
"endIndex": "Fim",
|
"endIndex": "Fim",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Preferências de Áudio",
|
"audio": "Preferências de Áudio",
|
||||||
"browserForCookies": "Selecionar navegador para usar cookies",
|
"browserForCookies": "Selecionar navegador para usar cookies",
|
||||||
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
||||||
|
"browserForCookiesWindowsNote": "No Windows, apenas cookies do Firefox são suportados. Para outros navegadores, configure um arquivo de cookies manualmente.",
|
||||||
"browserForCookiesProfile": "Nome do perfil ou caminho",
|
"browserForCookiesProfile": "Nome do perfil ou caminho",
|
||||||
"browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.",
|
"browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.",
|
||||||
"browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)",
|
"browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Desabilitado",
|
"disabled": "Desabilitado",
|
||||||
"onlyLatestShort": "Apenas o mais recente"
|
"onlyLatestShort": "Apenas o mais recente"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"refresh": "Atualizar",
|
"refresh": "Atualizar",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Este vídeo já está na fila",
|
"itemAlreadyQueued": "Este vídeo já está na fila",
|
||||||
"queueError": "Falha ao adicionar à fila de download.",
|
"queueError": "Falha ao adicionar à fila de download.",
|
||||||
"openLinkError": "Falha ao abrir o link do vídeo.",
|
"openLinkError": "Falha ao abrir o link do vídeo.",
|
||||||
"resolveError": "Falha ao resolver o URL do feed RSS."
|
"resolveError": "Falha ao resolver o URL do feed RSS.",
|
||||||
|
"duplicateUrl": "Este feed RSS já está inscrito."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Feed de {{platform}} detectado -> {{feed}}",
|
"detectedFeed": "Feed de {{platform}} detectado -> {{feed}}",
|
||||||
"detecting": "Detectando feed...",
|
"detecting": "Detectando feed...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "Поиск обновлений...",
|
"checkingUpdates": "Поиск обновлений...",
|
||||||
"downloadError": "Не удалось загрузить обновление",
|
"downloadError": "Не удалось загрузить обновление",
|
||||||
"downloadStarted": "Загрузка началась...",
|
|
||||||
"downloadUpdate": "Загрузить и установить обновление {{version}}?",
|
"downloadUpdate": "Загрузить и установить обновление {{version}}?",
|
||||||
"manualDownloadAction": "Скачать сейчас",
|
"manualDownloadAction": "Скачать сейчас",
|
||||||
"noUpdatesAvailable": "Вы используете последнюю версию",
|
"noUpdatesAvailable": "Вы используете последнюю версию",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "Ожидание",
|
"downloadPending": "Ожидание",
|
||||||
"downloadQueue": "Очередь загрузки",
|
"downloadQueue": "Очередь загрузки",
|
||||||
"customDownloadFolder": "Пользовательская папка загрузки",
|
"customDownloadFolder": "Пользовательская папка загрузки",
|
||||||
|
"retry": "Повторить загрузку",
|
||||||
"autoFolderPlaceholder": "Автоматическая папка (на основе метаданных)",
|
"autoFolderPlaceholder": "Автоматическая папка (на основе метаданных)",
|
||||||
"autoFolderHint": "Автоматические папки создаются из метаданных.",
|
"autoFolderHint": "Автоматические папки создаются из метаданных.",
|
||||||
"useAutoFolder": "Использовать автоматическую папку",
|
"useAutoFolder": "Использовать автоматическую папку",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"fetch": "Получить",
|
"fetch": "Получить",
|
||||||
"fetchingVideoInfo": "Получение информации о видео...",
|
"fetchingVideoInfo": "Получение информации о видео...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "Сообщить об этой ошибке:",
|
||||||
|
"githubUrlTooLong": "Эта ссылка GitHub очень длинная. Если она не открывается, откройте страницу issue и вставьте логи вручную."
|
||||||
|
},
|
||||||
"history": "История",
|
"history": "История",
|
||||||
"imageLoadError": "Не удалось загрузить изображение",
|
"imageLoadError": "Не удалось загрузить изображение",
|
||||||
"imagePlaceholder": "Изображение недоступно",
|
"imagePlaceholder": "Изображение недоступно",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "Прогресс",
|
"progress": "Прогресс",
|
||||||
"showDetails": "Показать детали",
|
"showDetails": "Показать детали",
|
||||||
"hideDetails": "Скрыть детали",
|
"hideDetails": "Скрыть детали",
|
||||||
|
"viewLogs": "Посмотреть логи",
|
||||||
|
"detailsTab": "Детали",
|
||||||
|
"logsTab": "Логи",
|
||||||
|
"logs": {
|
||||||
|
"live": "Логи в реальном времени",
|
||||||
|
"history": "Сохранённые логи",
|
||||||
|
"command": "Команда yt-dlp",
|
||||||
|
"empty": "Пока нет логов.",
|
||||||
|
"scrollPaused": "Прокрутка приостановлена"
|
||||||
|
},
|
||||||
"selectAudioFormat": "Выбрать формат аудио",
|
"selectAudioFormat": "Выбрать формат аудио",
|
||||||
"selectDownloadType": "Выберите тип загрузки",
|
"selectDownloadType": "Выберите тип загрузки",
|
||||||
"selectFormat": "Выбрать формат",
|
"selectFormat": "Выбрать формат",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "Примечание формата",
|
"formatNote": "Примечание формата",
|
||||||
"protocol": "Протокол",
|
"protocol": "Протокол",
|
||||||
"subscription": "Подписка"
|
"subscription": "Подписка"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "Нажмите, чтобы открыть в браузере",
|
"openInBrowser": "Нажмите, чтобы открыть в браузере",
|
||||||
"removeAction": "Удалить",
|
"removeAction": "Удалить",
|
||||||
"removeItem": "Удалить элемент",
|
"removeItem": "Удалить элемент",
|
||||||
|
"deleteFile": "Удалить файл",
|
||||||
|
"deleteRecord": "Удалить из списка",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"selectAll": "Выбрать все",
|
"selectAll": "Выбрать все",
|
||||||
"selectVisible": "Выбрать видимые",
|
"selectVisible": "Выбрать видимые",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "Не удалось скопировать в буфер обмена",
|
"copyFailed": "Не удалось скопировать в буфер обмена",
|
||||||
"downloadCompleted": "Загрузка завершена",
|
"downloadCompleted": "Загрузка завершена",
|
||||||
"downloadFailed": "Загрузка не удалась",
|
"downloadFailed": "Загрузка не удалась",
|
||||||
"downloadStarted": "Загрузка началась",
|
|
||||||
"historyCleared": "История очищена",
|
"historyCleared": "История очищена",
|
||||||
"historyClearFailed": "Не удалось очистить историю",
|
"historyClearFailed": "Не удалось очистить историю",
|
||||||
"itemRemoved": "Элемент удалён",
|
"itemRemoved": "Элемент удалён",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "Загрузить все видео из плейлиста или канала YouTube",
|
"description": "Загрузить все видео из плейлиста или канала YouTube",
|
||||||
"downloadFailed": "Не удалось начать загрузку плейлиста",
|
"downloadFailed": "Не удалось начать загрузку плейлиста",
|
||||||
"downloadPlaylist": "Загрузить плейлист",
|
"downloadPlaylist": "Загрузить плейлист",
|
||||||
"downloadStarted": "Начата загрузка {{count}} видео из плейлиста",
|
|
||||||
"downloadType": "Тип загрузки",
|
"downloadType": "Тип загрузки",
|
||||||
"downloading": "Загрузка плейлиста:",
|
"downloading": "Загрузка плейлиста:",
|
||||||
"endIndex": "Конец",
|
"endIndex": "Конец",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "Настройки аудио",
|
"audio": "Настройки аудио",
|
||||||
"browserForCookies": "Выбрать браузер для использования cookie",
|
"browserForCookies": "Выбрать браузер для использования cookie",
|
||||||
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
|
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
|
||||||
|
"browserForCookiesWindowsNote": "В Windows поддерживаются только cookie Firefox. Для других браузеров вручную укажите файл cookie.",
|
||||||
"browserForCookiesProfile": "Имя профиля или путь",
|
"browserForCookiesProfile": "Имя профиля или путь",
|
||||||
"browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.",
|
"browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.",
|
||||||
"browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)",
|
"browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "Отключено",
|
"disabled": "Отключено",
|
||||||
"onlyLatestShort": "Только последнее"
|
"onlyLatestShort": "Только последнее"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"refresh": "Обновить",
|
"refresh": "Обновить",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "Это видео уже в очереди",
|
"itemAlreadyQueued": "Это видео уже в очереди",
|
||||||
"queueError": "Не удалось добавить в очередь загрузки.",
|
"queueError": "Не удалось добавить в очередь загрузки.",
|
||||||
"openLinkError": "Не удалось открыть ссылку на видео.",
|
"openLinkError": "Не удалось открыть ссылку на видео.",
|
||||||
"resolveError": "Не удалось разрешить URL RSS-канала."
|
"resolveError": "Не удалось разрешить URL RSS-канала.",
|
||||||
|
"duplicateUrl": "Этот RSS-канал уже добавлен."
|
||||||
},
|
},
|
||||||
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
|
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
|
||||||
"detecting": "Определение канала...",
|
"detecting": "Определение канала...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "正在搜尋更新...",
|
"checkingUpdates": "正在搜尋更新...",
|
||||||
"downloadError": "下載更新失敗",
|
"downloadError": "下載更新失敗",
|
||||||
"downloadStarted": "開始下載...",
|
|
||||||
"downloadUpdate": "下載並安裝更新 {{version}}?",
|
"downloadUpdate": "下載並安裝更新 {{version}}?",
|
||||||
"manualDownloadAction": "立即下載",
|
"manualDownloadAction": "立即下載",
|
||||||
"noUpdatesAvailable": "您正在使用最新版本",
|
"noUpdatesAvailable": "您正在使用最新版本",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "待處理",
|
"downloadPending": "待處理",
|
||||||
"downloadQueue": "下載佇列",
|
"downloadQueue": "下載佇列",
|
||||||
"customDownloadFolder": "自訂下載資料夾",
|
"customDownloadFolder": "自訂下載資料夾",
|
||||||
|
"retry": "重試下載",
|
||||||
"autoFolderPlaceholder": "自動資料夾(依中繼資料)",
|
"autoFolderPlaceholder": "自動資料夾(依中繼資料)",
|
||||||
"autoFolderHint": "自動資料夾會由中繼資料建立。",
|
"autoFolderHint": "自動資料夾會由中繼資料建立。",
|
||||||
"useAutoFolder": "使用自動資料夾",
|
"useAutoFolder": "使用自動資料夾",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "錯誤",
|
"error": "錯誤",
|
||||||
"fetch": "取得",
|
"fetch": "取得",
|
||||||
"fetchingVideoInfo": "正在取得影片資訊...",
|
"fetchingVideoInfo": "正在取得影片資訊...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "報告此錯誤:",
|
||||||
|
"githubUrlTooLong": "這個 GitHub 連結很長。如果無法開啟,請開啟 issue 頁面並手動貼上日誌。"
|
||||||
|
},
|
||||||
"history": "歷史",
|
"history": "歷史",
|
||||||
"imageLoadError": "圖片載入失敗",
|
"imageLoadError": "圖片載入失敗",
|
||||||
"imagePlaceholder": "暫無圖片",
|
"imagePlaceholder": "暫無圖片",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "進度",
|
"progress": "進度",
|
||||||
"showDetails": "顯示詳情",
|
"showDetails": "顯示詳情",
|
||||||
"hideDetails": "隱藏詳細信息",
|
"hideDetails": "隱藏詳細信息",
|
||||||
|
"viewLogs": "查看日誌",
|
||||||
|
"detailsTab": "詳細資料",
|
||||||
|
"logsTab": "日誌",
|
||||||
|
"logs": {
|
||||||
|
"live": "即時日誌",
|
||||||
|
"history": "已儲存日誌",
|
||||||
|
"command": "yt-dlp 指令",
|
||||||
|
"empty": "尚無日誌。",
|
||||||
|
"scrollPaused": "捲動已暫停"
|
||||||
|
},
|
||||||
"selectAudioFormat": "選擇音訊格式",
|
"selectAudioFormat": "選擇音訊格式",
|
||||||
"selectDownloadType": "選擇下載類型",
|
"selectDownloadType": "選擇下載類型",
|
||||||
"selectFormat": "選擇格式",
|
"selectFormat": "選擇格式",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "格式註釋",
|
"formatNote": "格式註釋",
|
||||||
"protocol": "協定",
|
"protocol": "協定",
|
||||||
"subscription": "訂閱"
|
"subscription": "訂閱"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "點擊在瀏覽器中開啟",
|
"openInBrowser": "點擊在瀏覽器中開啟",
|
||||||
"removeAction": "移除",
|
"removeAction": "移除",
|
||||||
"removeItem": "移除項目",
|
"removeItem": "移除項目",
|
||||||
|
"deleteFile": "刪除檔案",
|
||||||
|
"deleteRecord": "從列表中移除",
|
||||||
"select": "選取",
|
"select": "選取",
|
||||||
"selectAll": "全選",
|
"selectAll": "全選",
|
||||||
"selectVisible": "選取可見項目",
|
"selectVisible": "選取可見項目",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "複製到剪貼簿失敗",
|
"copyFailed": "複製到剪貼簿失敗",
|
||||||
"downloadCompleted": "下載完成",
|
"downloadCompleted": "下載完成",
|
||||||
"downloadFailed": "下載失敗",
|
"downloadFailed": "下載失敗",
|
||||||
"downloadStarted": "下載已開始",
|
|
||||||
"historyCleared": "歷史紀錄已清除",
|
"historyCleared": "歷史紀錄已清除",
|
||||||
"historyClearFailed": "清除歷史紀錄失敗",
|
"historyClearFailed": "清除歷史紀錄失敗",
|
||||||
"itemRemoved": "項目已移除",
|
"itemRemoved": "項目已移除",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
||||||
"downloadFailed": "啟動播放清單下載失敗",
|
"downloadFailed": "啟動播放清單下載失敗",
|
||||||
"downloadPlaylist": "下載播放清單",
|
"downloadPlaylist": "下載播放清單",
|
||||||
"downloadStarted": "已開始下載播放清單中的 {{count}} 個影片",
|
|
||||||
"downloadType": "下載類型",
|
"downloadType": "下載類型",
|
||||||
"downloading": "正在下載播放清單:",
|
"downloading": "正在下載播放清單:",
|
||||||
"endIndex": "結束",
|
"endIndex": "結束",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "音訊偏好",
|
"audio": "音訊偏好",
|
||||||
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
||||||
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
||||||
|
"browserForCookiesWindowsNote": "Windows 僅支援 Firefox 的 cookie,其他瀏覽器請手動設定 cookie 檔案。",
|
||||||
"browserForCookiesProfile": "設定檔名稱或路徑",
|
"browserForCookiesProfile": "設定檔名稱或路徑",
|
||||||
"browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。",
|
"browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。",
|
||||||
"browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)",
|
"browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "殘疾人",
|
"disabled": "殘疾人",
|
||||||
"onlyLatestShort": "僅最新"
|
"onlyLatestShort": "僅最新"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"refresh": "重新整理",
|
"refresh": "重新整理",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "該視頻已排隊",
|
"itemAlreadyQueued": "該視頻已排隊",
|
||||||
"queueError": "無法添加到下載隊列。",
|
"queueError": "無法添加到下載隊列。",
|
||||||
"openLinkError": "無法打開視頻鏈接。",
|
"openLinkError": "無法打開視頻鏈接。",
|
||||||
"resolveError": "無法解析 RSS 源 URL。"
|
"resolveError": "無法解析 RSS 源 URL。",
|
||||||
|
"duplicateUrl": "此 RSS 訂閱已存在。"
|
||||||
},
|
},
|
||||||
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
||||||
"detecting": "檢測飼料...",
|
"detecting": "檢測飼料...",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"checkingUpdates": "正在查找更新...",
|
"checkingUpdates": "正在查找更新...",
|
||||||
"downloadError": "下载更新失败",
|
"downloadError": "下载更新失败",
|
||||||
"downloadStarted": "开始下载...",
|
|
||||||
"downloadUpdate": "下载并安装更新 {{version}}?",
|
"downloadUpdate": "下载并安装更新 {{version}}?",
|
||||||
"manualDownloadAction": "立即下载",
|
"manualDownloadAction": "立即下载",
|
||||||
"noUpdatesAvailable": "您正在使用最新版本",
|
"noUpdatesAvailable": "您正在使用最新版本",
|
||||||
@@ -120,6 +119,7 @@
|
|||||||
"downloadPending": "待处理",
|
"downloadPending": "待处理",
|
||||||
"downloadQueue": "下载队列",
|
"downloadQueue": "下载队列",
|
||||||
"customDownloadFolder": "自定义下载文件夹",
|
"customDownloadFolder": "自定义下载文件夹",
|
||||||
|
"retry": "重试下载",
|
||||||
"autoFolderPlaceholder": "自动文件夹(基于元数据)",
|
"autoFolderPlaceholder": "自动文件夹(基于元数据)",
|
||||||
"autoFolderHint": "自动文件夹由元数据创建。",
|
"autoFolderHint": "自动文件夹由元数据创建。",
|
||||||
"useAutoFolder": "使用自动文件夹",
|
"useAutoFolder": "使用自动文件夹",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
"error": "错误",
|
"error": "错误",
|
||||||
"fetch": "获取",
|
"fetch": "获取",
|
||||||
"fetchingVideoInfo": "正在获取视频信息...",
|
"fetchingVideoInfo": "正在获取视频信息...",
|
||||||
|
"feedback": {
|
||||||
|
"title": "报告此错误:",
|
||||||
|
"githubUrlTooLong": "这个 GitHub 链接很长。如果无法打开,请打开 issue 页面并手动粘贴日志。"
|
||||||
|
},
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
"imageLoadError": "图像加载失败",
|
"imageLoadError": "图像加载失败",
|
||||||
"imagePlaceholder": "暂无图像",
|
"imagePlaceholder": "暂无图像",
|
||||||
@@ -155,6 +159,16 @@
|
|||||||
"progress": "进度",
|
"progress": "进度",
|
||||||
"showDetails": "显示详情",
|
"showDetails": "显示详情",
|
||||||
"hideDetails": "隐藏详细信息",
|
"hideDetails": "隐藏详细信息",
|
||||||
|
"viewLogs": "查看日志",
|
||||||
|
"detailsTab": "详情",
|
||||||
|
"logsTab": "日志",
|
||||||
|
"logs": {
|
||||||
|
"live": "实时日志",
|
||||||
|
"history": "已保存日志",
|
||||||
|
"command": "yt-dlp 命令",
|
||||||
|
"empty": "暂无日志。",
|
||||||
|
"scrollPaused": "滚动已暂停"
|
||||||
|
},
|
||||||
"selectAudioFormat": "选择音频格式",
|
"selectAudioFormat": "选择音频格式",
|
||||||
"selectDownloadType": "选择下载类型",
|
"selectDownloadType": "选择下载类型",
|
||||||
"selectFormat": "选择格式",
|
"selectFormat": "选择格式",
|
||||||
@@ -195,9 +209,6 @@
|
|||||||
"formatNote": "格式注释",
|
"formatNote": "格式注释",
|
||||||
"protocol": "协议",
|
"protocol": "协议",
|
||||||
"subscription": "订阅"
|
"subscription": "订阅"
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"title": "Report this error:"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -269,6 +280,8 @@
|
|||||||
"openInBrowser": "点击在浏览器中打开",
|
"openInBrowser": "点击在浏览器中打开",
|
||||||
"removeAction": "移除",
|
"removeAction": "移除",
|
||||||
"removeItem": "移除项目",
|
"removeItem": "移除项目",
|
||||||
|
"deleteFile": "删除文件",
|
||||||
|
"deleteRecord": "从列表中移除",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"selectVisible": "选择可见项",
|
"selectVisible": "选择可见项",
|
||||||
@@ -302,7 +315,6 @@
|
|||||||
"copyFailed": "复制到剪贴板失败",
|
"copyFailed": "复制到剪贴板失败",
|
||||||
"downloadCompleted": "下载完成",
|
"downloadCompleted": "下载完成",
|
||||||
"downloadFailed": "下载失败",
|
"downloadFailed": "下载失败",
|
||||||
"downloadStarted": "下载已开始",
|
|
||||||
"historyCleared": "历史记录已清除",
|
"historyCleared": "历史记录已清除",
|
||||||
"historyClearFailed": "清除历史记录失败",
|
"historyClearFailed": "清除历史记录失败",
|
||||||
"itemRemoved": "项目已移除",
|
"itemRemoved": "项目已移除",
|
||||||
@@ -326,7 +338,6 @@
|
|||||||
"description": "下载 YouTube 播放列表或频道中的全部视频",
|
"description": "下载 YouTube 播放列表或频道中的全部视频",
|
||||||
"downloadFailed": "启动播放列表下载失败",
|
"downloadFailed": "启动播放列表下载失败",
|
||||||
"downloadPlaylist": "下载播放列表",
|
"downloadPlaylist": "下载播放列表",
|
||||||
"downloadStarted": "已开始下载播放列表中的 {{count}} 个视频",
|
|
||||||
"downloadType": "下载类型",
|
"downloadType": "下载类型",
|
||||||
"downloading": "正在下载播放列表:",
|
"downloading": "正在下载播放列表:",
|
||||||
"endIndex": "结束",
|
"endIndex": "结束",
|
||||||
@@ -370,6 +381,7 @@
|
|||||||
"audio": "音频偏好",
|
"audio": "音频偏好",
|
||||||
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
||||||
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
||||||
|
"browserForCookiesWindowsNote": "Windows 仅支持 Firefox 的 cookie,其他浏览器请手动配置 cookie 文件。",
|
||||||
"browserForCookiesProfile": "配置文件名称或路径",
|
"browserForCookiesProfile": "配置文件名称或路径",
|
||||||
"browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。",
|
"browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。",
|
||||||
"browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)",
|
"browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)",
|
||||||
@@ -490,9 +502,6 @@
|
|||||||
"disabled": "残疾人",
|
"disabled": "残疾人",
|
||||||
"onlyLatestShort": "仅最新"
|
"onlyLatestShort": "仅最新"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
|
||||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
|
||||||
},
|
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
@@ -545,7 +554,8 @@
|
|||||||
"itemAlreadyQueued": "该视频已排队",
|
"itemAlreadyQueued": "该视频已排队",
|
||||||
"queueError": "无法添加到下载队列。",
|
"queueError": "无法添加到下载队列。",
|
||||||
"openLinkError": "无法打开视频链接。",
|
"openLinkError": "无法打开视频链接。",
|
||||||
"resolveError": "无法解析 RSS 源 URL。"
|
"resolveError": "无法解析 RSS 源 URL。",
|
||||||
|
"duplicateUrl": "该 RSS 订阅已存在。"
|
||||||
},
|
},
|
||||||
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
|
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
|
||||||
"detecting": "检测饲料...",
|
"detecting": "检测饲料...",
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ export function About() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<FeedbackLinkButtons
|
<FeedbackLinkButtons
|
||||||
appInfo={{ appVersion, osVersion }}
|
appInfo={{ appVersion, osVersion }}
|
||||||
issueTitle="[bug]: "
|
useSimpleGithubUrl={true}
|
||||||
buttonClassName="gap-2"
|
buttonClassName="gap-2"
|
||||||
iconClassName="h-4 w-4"
|
iconClassName="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -520,30 +520,26 @@ export function Settings() {
|
|||||||
|
|
||||||
<ItemSeparator />
|
<ItemSeparator />
|
||||||
|
|
||||||
{platform !== 'darwin' && (
|
<Item variant="muted">
|
||||||
<>
|
<ItemContent>
|
||||||
<Item variant="muted">
|
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||||
<ItemContent>
|
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
</ItemContent>
|
||||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
<ItemActions>
|
||||||
</ItemContent>
|
<Switch
|
||||||
<ItemActions>
|
checked={settings.embedThumbnail ?? false}
|
||||||
<Switch
|
onCheckedChange={(value) => {
|
||||||
checked={settings.embedThumbnail ?? false}
|
try {
|
||||||
onCheckedChange={(value) => {
|
handleSettingChange('embedThumbnail', value)
|
||||||
try {
|
} catch (error) {
|
||||||
handleSettingChange('embedThumbnail', value)
|
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||||
} catch (error) {
|
}
|
||||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
}}
|
||||||
}
|
/>
|
||||||
}}
|
</ItemActions>
|
||||||
/>
|
</Item>
|
||||||
</ItemActions>
|
|
||||||
</Item>
|
|
||||||
|
|
||||||
<ItemSeparator />
|
<ItemSeparator />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
@@ -676,6 +672,9 @@ export function Settings() {
|
|||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||||
|
{platform === 'win32' && (
|
||||||
|
<ItemDescription>{t('settings.browserForCookiesWindowsNote')}</ItemDescription>
|
||||||
|
)}
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
updateSubscriptionAtom
|
updateSubscriptionAtom
|
||||||
} from '@renderer/store/subscriptions'
|
} from '@renderer/store/subscriptions'
|
||||||
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
|
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
|
||||||
|
import { SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '@shared/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
|
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
@@ -87,6 +88,41 @@ const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
|
|||||||
cancelled: 'subscriptions.items.status.cancelled'
|
cancelled: 'subscriptions.items.status.cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string | undefined => {
|
||||||
|
if (!error) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (typeof error === 'object') {
|
||||||
|
if ('message' in error && typeof error.message === 'string') {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if ('code' in error && typeof error.code === 'string') {
|
||||||
|
return error.code
|
||||||
|
}
|
||||||
|
if ('error' in error) {
|
||||||
|
const nested = (error as { error?: unknown }).error
|
||||||
|
if (typeof nested === 'string') {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
if (nested && typeof nested === 'object' && 'message' in nested) {
|
||||||
|
const nestedMessage = (nested as { message?: unknown }).message
|
||||||
|
if (typeof nestedMessage === 'string') {
|
||||||
|
return nestedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDuplicateFeedError = (error: unknown) => {
|
||||||
|
const message = getErrorMessage(error)
|
||||||
|
return Boolean(message?.includes(SUBSCRIPTION_DUPLICATE_FEED_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
function SubscriptionTab({
|
function SubscriptionTab({
|
||||||
subscription,
|
subscription,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -252,8 +288,17 @@ export function Subscriptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSubscription({ id, data: updatePayload })
|
try {
|
||||||
await refreshSubscription(id)
|
await updateSubscription({ id, data: updatePayload })
|
||||||
|
await refreshSubscription(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update subscription:', error)
|
||||||
|
toast.error(
|
||||||
|
isDuplicateFeedError(error)
|
||||||
|
? t('subscriptions.notifications.duplicateUrl')
|
||||||
|
: t('subscriptions.notifications.createError')
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[refreshSubscription, updateSubscription, resolveFeed, t]
|
[refreshSubscription, updateSubscription, resolveFeed, t]
|
||||||
)
|
)
|
||||||
@@ -281,7 +326,11 @@ export function Subscriptions() {
|
|||||||
setAddDialogOpen(false)
|
setAddDialogOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create subscription:', error)
|
console.error('Failed to create subscription:', error)
|
||||||
toast.error(t('subscriptions.notifications.createError'))
|
toast.error(
|
||||||
|
isDuplicateFeedError(error)
|
||||||
|
? t('subscriptions.notifications.duplicateUrl')
|
||||||
|
: t('subscriptions.notifications.createError')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createSubscription, t]
|
[createSubscription, t]
|
||||||
|
|||||||