Compare commits

...

49 Commits
v1.1.6 ... main

Author SHA1 Message Date
Nexmoe
f646f287e6 chore: release v1.2.1 2026-01-20 22:48:17 +08:00
Nexmoe
603a4f051d feat(download): add log capture and file actions (#157)
* feat(download): add log capture and file actions

* fix(ci): update ffmpeg download source

* fix(ci): consolidate ffmpeg download source to yt-dlp repository

Update FFmpeg download URLs to use yt-dlp/FFmpeg-Builds as the primary source across all platforms and remove BtbN fallback repository reference.
2026-01-20 22:47:18 +08:00
Nexmoe
e2bfa334c0 docs(protocol): add vidbee protocol docs (#150) 2026-01-18 14:13:37 +08:00
Nexmoe
e8769fefb0 fix(download): disambiguate duplicate playlist titles (#145) 2026-01-18 13:34:39 +08:00
Nexmoe
b88411d229 fix(subscription): update rsshub links (#146)
* chore(docs): update rsshub documentation URLs to https://docs.rsshub.app/routes/

Update rsshub-related documentation URLs to use the official documentation base URL.

Changes:
- Update bilibili feed URL generation in subscription-service.ts
- Update RSSHub documentation link in SubscriptionFormDialog.tsx to point to docs.rsshub.app/routes/

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* chore(i18n): remove placeholder URL from i18n, use hardcoded URL instead

Remove the placeholders.url entry from all locale files and hardcode the
example URL directly in the SubscriptionFormDialog component. The placeholder
URL is a technical example and doesn't need internationalization.

Changes:
- Hardcode placeholder URL in SubscriptionFormDialog: "https://docs.rsshub.app/routes/youtube/user/@FKJ"
- Remove placeholders.url entry from all 14 locale files

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(subscription): update bilibili rsshub url

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 13:34:32 +08:00
Nexmoe
b6c67b23f6 feat(docs): generate sitemap for docs site (#148)
* docs: add VidBee Docs suffix to all documentation page titles

Update the page title metadata generation to append "| VidBee Docs" to all documentation page titles for consistent branding across the site.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* docs: add canonical URLs to documentation pages

Add canonical URL metadata to all documentation pages to prevent search engine duplication issues and improve SEO rankings.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(docs): add automatic sitemap

* fix(docs): align canonical base URL

* fix(docs): configure sitemap for static export

Add 'export const dynamic = "force-static"' to sitemap.ts to enable
static generation when using Next.js output: export mode. This fixes
the build error: "export const dynamic = 'force-static'/export const
revalidate not configured on route '/sitemap.xml'".

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore(docs): add favicon and icon assets

Add favicons and app icons in various sizes for better browser
and platform support.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 13:26:34 +08:00
Nexmoe
cab86fe71c Remove download started toast notifications (#143)
* Remove download started toast notifications

Remove the "download started" toast notifications from single video and playlist downloads, keeping only the one-click download notification. Also remove the corresponding unused translation strings from all 13 locale files.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Restore oneClickDownloadStarted translation key in all locales

The oneClickDownloadStarted key is still referenced in the code when one-click downloads start, so it must be kept in all locale files.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Translate feedback.title in all non-English locales

The feedback.title field was showing English text in all locale files. Updated to proper translations for each language.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 17:27:11 +08:00
Nexmoe
129899ed61 fix(docs): resolve TypeScript build errors (#144)
* fix(docs): resolve TypeScript build errors and search configuration

Fix invalid LayoutProps type in [lang]/(docs)/layout.tsx to use proper Next.js 16 async layout component signature. Add type assertion in i18n.ts isLocale function to satisfy strict type checking. Configure search to use English indexing since Orama doesn't support Chinese.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(docs): configure static export following Fumadocs guidelines

- Enable Next.js static export with output: 'export'
- Configure static search using staticGET and revalidate: false
- Update search UI to use type: 'static' for client-side search
- Add localeMap to support both English and Chinese locales
- Remove rewrites (incompatible with static export)
- Remove route handlers that conflict with static export:
  - /llms.mdx route (file extension conflicts)
  - /llms-full.txt route
  - /og/docs OG image generation
- Add trailingSlash: true for better route handling

Build successfully generates 9 static pages (2.7MB total).
Search indexes are exported as static files for client-side use.

Reference: https://www.fumadocs.dev/docs/deploying/static

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(docs): resolve 404 issues in static export

- Change hideLocale from 'default-locale' to 'never' for static export
  compatibility (middleware doesn't work in static builds)
- Add public/index.html to redirect root path to /en/
- Update middleware matcher to remove deleted route exclusions
- All pages now accessible with language prefix (/en/, /zh/)

This fixes the 404 issue where middleware-based rewrites don't work
in static exports. Now all URLs explicitly include the language code.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(docs): serve English content at root path without redirect

- Restore hideLocale to 'default-locale' for cleaner URLs
- Add post-export script to copy /en/ content to root directory
- English pages now accessible at both / and /en/
- Chinese pages remain at /zh/
- Remove redirect page in favor of direct content serving
- Add "type": "module" to package.json for ES modules

This provides a better user experience by serving English content
directly at the root path (/, /cookies/, /faq/) without requiring
a redirect, while maintaining /en/ for explicit access.

Output size: 3.2MB (includes both root and /en/ copies)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 16:31:24 +08:00
Nexmoe
1f821b67dd docs: add fumadocs documentation site (#142) 2026-01-17 14:37:43 +08:00
Nexmoe
6f1c6ce59e chore: release v1.2.0 2026-01-17 13:58:17 +08:00
Nexmoe
e5d36da904 fix(ffmpeg): require ffmpeg/ffprobe bundle directory, closes #94 #106 #105 #69 #133 (#139)
* fix(ffmpeg): require bundled ffprobe directory

* ci(build): upload artifacts on PR

* fix(download): allow embed thumbnail on macos
2026-01-17 13:35:29 +08:00
Nexmoe
9c3fd2c26f fix(settings): show windows-only cookies note (#141) 2026-01-17 13:23:45 +08:00
Nexmoe
894eb9774b fix(download): persist resume sessions (#137)
* fix(download): persist resume sessions

* fix(ipc): cleanup download listeners
2026-01-17 13:00:36 +08:00
Nexmoe
73af211bef feat(feedback): warn on long github issue urls (#140) 2026-01-17 12:52:30 +08:00
Nexmoe
4b561cef38 feat(download): add select all and clear history (#136)
* feat(download): add select all and clear history

* fix(download): remove clear all history action

* feat(download): clear selection on escape

* feat(download): select full playlist groups

* style(download): adjust playlist group spacing
2026-01-17 12:47:54 +08:00
Nexmoe
761adf2476 feat(tray): minimize to tray by default (#138) 2026-01-17 12:17:34 +08:00
Nexmoe
182a1b9c1e fix(subscriptions): detect duplicate RSS feeds (#134) 2026-01-17 11:50:08 +08:00
Nexmoe
59aee07913 fix(download): constrain playlist list height (#130)
* fix(download): constrain playlist list height

* fix(ci): handle ffmpeg timeout on windows
2026-01-16 21:58:33 +08:00
Nexmoe
3d39c9751f fix(feedback): simplify github issue link (#131) 2026-01-16 21:27:29 +08:00
Nexmoe
2bf7af9b25 chore: release v1.1.12 2026-01-15 21:23:02 +08:00
Nexmoe
fe55ac1a79 fix(settings): avoid creating default download folder (#116) 2026-01-15 21:21:09 +08:00
Nexmoe
c1d1a1b912 feat(feedback): attach yt-dlp command (#114) 2026-01-15 21:07:37 +08:00
Nexmoe
9d377dd464 refactor(download): split dialog into single/playlist (#115) 2026-01-15 20:49:24 +08:00
Nexmoe
be00f5b2a4 chore: release v1.1.11 2026-01-14 22:07:22 +08:00
Nexmoe
2d69ce81a4 feat(download): enrich error feedback links (#101)
* feat(download): enrich feedback payloads

* fix(ci): stabilize locale and ffmpeg fetch
2026-01-14 21:50:42 +08:00
Nexmoe
4a9ee1406b fix(subscriptions): add horizontal tab scroll (#93)
* fix(subscriptions): add horizontal tab scroll

* fix(ui): add scroll areas to pages

* fix(downloads): adjust history spacing
2026-01-14 20:26:00 +08:00
Nexmoe
aed6842319 fix(i18n): fill missing locale translations (#100)
* fix(i18n): fill missing locale translations

* fix(i18n): keep interpolation keys
2026-01-14 20:25:39 +08:00
Nexmoe
e7957d09ec fix(settings): disable embed thumbnail by default (#99) 2026-01-14 19:54:43 +08:00
Nexmoe
5057d18e09 fix(renderer): disable text selection (#98) 2026-01-14 19:54:41 +08:00
Nexmoe
3c1f52b06b fix(subscriptions): update rsshub docs link (#97) 2026-01-14 19:54:37 +08:00
Nexmoe
f061b6b61e fix(renderer): refine subscriptions layout (#92) 2026-01-13 22:20:31 +08:00
Nexmoe
43cabf65e9 feat(subscriptions): follow one-click defaults (#91) 2026-01-13 21:41:00 +08:00
Nexmoe
da3643bc79 fix(download): guard bilibili subtitle embed (#90) 2026-01-13 21:30:33 +08:00
Nexmoe
976cb82bf9 feat(download): refine progress and dialog layout (#88) 2026-01-13 21:02:23 +08:00
Nexmoe
14f80309af feat(extension): refine error UI and branding (#89) 2026-01-13 21:02:20 +08:00
Nexmoe
8acd761189 chore: release v1.1.10 2026-01-12 22:29:52 +08:00
Nexmoe
20c6ae6543 fix(ci): notarize dmg in workflow (#87)
* chore: release v1.1.9

* fix(ci): notarize dmg in workflow
2026-01-12 22:29:14 +08:00
Nexmoe
66f2211d68 fix(build): move mac tool signing to beforeSign (#86)
* fix(build): move tool signing to beforeSign

* ci(macos): verify signing and notarization

* fix(build): move signing hook to afterPack
2026-01-12 21:53:39 +08:00
Nexmoe
ac183b4e3c build(mac): sign bundled tools (#85) 2026-01-12 21:19:18 +08:00
Nexmoe
877f088136 chore: release v1.1.8 2026-01-12 10:14:49 +08:00
Nexmoe
5ee08a3e62 chore(build): add macOS signing secrets and validation
- Introduced new secrets for macOS signing in the build workflow.
- Added validation to check for the presence of required secrets before proceeding with code signing setup.
2026-01-12 10:13:58 +08:00
Nexmoe
137a07cf4e chore(build): enhance macOS signing and notarization process
- Updated electron-builder configuration to enable hardened runtime and notarization for macOS builds.
- Added a new GitHub Actions step to set up macOS signing with necessary certificates and keys.
2026-01-12 09:58:44 +08:00
Nexmoe
21a5adbdf2 fix(update): localize update toast (#82) 2026-01-11 16:56:34 +08:00
Nexmoe
81f52f86e5 fix(about): hide check updates when available (#80) 2026-01-11 16:34:48 +08:00
Nexmoe
c8147cafe3 fix(download): inline progress details (#81) 2026-01-11 16:34:40 +08:00
Nexmoe
7074f698e5 chore: release v1.1.7 2026-01-11 15:46:37 +08:00
Nexmoe
b254bb6dba chore(build): reduce packaged artifacts (#79) 2026-01-11 15:44:20 +08:00
Nexmoe
1caca0ea9a feat(settings): add embed features (#76)
* feat(settings): add embed options

* feat(settings): default embed on

* refactor(setup-dev-binaries): enhance download error handling and version checks for binaries
2026-01-11 15:24:33 +08:00
Nexmoe
12ec142a77 ci(release): notify Cloudflare Pages (#78) 2026-01-11 15:21:40 +08:00
129 changed files with 13030 additions and 3801 deletions

View File

@@ -8,6 +8,17 @@ on:
type: boolean
default: false
description: 'Whether to upload build artifacts'
secrets:
MAC_CERT_P12_BASE64:
required: false
MAC_CERT_P12_PASSWORD:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_API_KEY_P8_BASE64:
required: false
jobs:
build:
@@ -22,16 +33,16 @@ jobs:
ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
ffmpeg_output: ffmpeg.exe
ffprobe_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
ffmpeg_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/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip
ffmpeg_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos
ffprobe_inner_path: ffmpeg/ffprobe
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
@@ -39,7 +50,7 @@ jobs:
ytdlp_output: yt-dlp_linux
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux
ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe
steps:
- name: Check out Git repository
uses: actions/checkout@v4
@@ -66,14 +77,20 @@ jobs:
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
Copy-Item -Path $source -Destination $destination -Force
$ffprobeSource = Join-Path 'ffmpeg' '${{ matrix.ffprobe_inner_path }}'
$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 -Recurse -Force
- name: Download ffmpeg binary (macOS)
if: matrix.platform == 'macos'
shell: bash
env:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
FFMPEG_OUTPUT: ffmpeg
FFPROBE_OUTPUT: ffprobe
run: |
set -euo pipefail
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
@@ -84,6 +101,21 @@ jobs:
arm_bin="ffmpeg-arm/${{ 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
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
fi
if [[ ! -f "$x86_bin" ]]; then
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
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
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
@@ -93,9 +125,19 @@ jobs:
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
exit 1
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"
chmod +x "resources/$FFMPEG_OUTPUT"
mkdir -p resources/ffmpeg
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
- name: Download ffmpeg binary (Linux)
@@ -110,8 +152,11 @@ jobs:
fi
mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
chmod +x "resources/${{ matrix.ffmpeg_output }}"
mkdir -p resources/ffmpeg
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
- name: Download yt-dlp binary
shell: bash
@@ -124,13 +169,100 @@ jobs:
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Setup macOS signing
if: matrix.platform == 'macos'
shell: bash
env:
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
# Check if all required secrets are present
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
echo "::notice::macOS signing secrets not available, skipping code signing setup"
exit 0
fi
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
- name: Build application
run: ${{ matrix.build_script }}
- name: Verify macOS codesign and notarization
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
shell: bash
run: |
set -euo pipefail
apps_found=0
while IFS= read -r app; do
apps_found=1
echo "Verifying codesign for $app"
codesign --verify --deep --strict --verbose=2 "$app"
spctl -a -t exec -vv "$app"
echo "Validating notarization ticket for $app"
xcrun stapler validate "$app"
done < <(find dist -type d -name "*.app" -prune -print)
if [[ "$apps_found" -eq 0 ]]; then
echo "::error::No .app bundles found in dist"
exit 1
fi
dmgs_found=0
while IFS= read -r dmg; do
dmgs_found=1
echo "Submitting DMG for notarization: $dmg"
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
echo "Stapling notarization ticket for $dmg"
xcrun stapler staple "$dmg"
echo "Validating notarization ticket for $dmg"
xcrun stapler validate "$dmg"
done < <(find dist -type f -name "*.dmg" -print)
if [[ "$dmgs_found" -eq 0 ]]; then
echo "::notice::No DMG artifacts found to validate"
fi
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
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

View File

@@ -7,3 +7,5 @@ on:
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true

84
.github/workflows/extension-publish.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Publish Extension
on:
push:
branches: [ main ]
paths:
- extension/package.json
jobs:
detect-version:
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.version_check.outputs.changed }}
current_version: ${{ steps.version_check.outputs.current_version }}
previous_version: ${{ steps.version_check.outputs.previous_version }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if extension version changed
id: version_check
shell: bash
run: |
set -euo pipefail
before_sha="${{ github.event.before }}"
current_version=$(node -e "const fs = require('fs'); const v = JSON.parse(fs.readFileSync('extension/package.json', 'utf8')).version; process.stdout.write(v);")
previous_version=""
if git cat-file -e "${before_sha}:extension/package.json" 2>/dev/null; then
previous_version=$(git show "${before_sha}:extension/package.json" | node -e "let data=''; process.stdin.on('data', d => data += d); process.stdin.on('end', () => { const v = JSON.parse(data).version; process.stdout.write(v); });")
fi
echo "current_version=${current_version}" >> "$GITHUB_OUTPUT"
echo "previous_version=${previous_version}" >> "$GITHUB_OUTPUT"
if [[ -n "${previous_version}" && "${current_version}" == "${previous_version}" ]]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
submit:
needs: detect-version
if: needs.detect-version.outputs.changed == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: extension
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: extension/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Zip extensions
run: |
pnpm zip
- name: Submit to stores
run: |
pnpm wxt submit \
--chrome-zip .output/*-chrome.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
CHROME_PUBLISH_TARGET: "default"
CHROME_SKIP_SUBMIT_REVIEW: false

View File

@@ -10,6 +10,7 @@ jobs:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
secrets: inherit
release:
needs: [build]
@@ -42,3 +43,9 @@ jobs:
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Notify Cloudflare Pages
env:
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
run: |
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"

View File

@@ -8,10 +8,6 @@ on:
types: [created, edited]
discussion_comment:
types: [created, edited]
pull_request_target:
types: [opened, edited]
pull_request_review_comment:
types: [created, edited]
jobs:
translate:

View File

@@ -1,6 +1,7 @@
1. use pnpm instead of npm
2. use pnpm run check after tasks to check code
3. Support i18n, only translate the English version of en.json
3. Support i18n. When writing business logic, initially only translate the English version of en.json
4. use English for comments&console
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
7. If there is an error when running `pnpm run check:i18n`, please complete the missing corresponding translation files and fields. Ensure the translation is done into the corresponding language, rather than directly copying the English version.

View File

@@ -58,7 +58,7 @@ src/
- Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
- 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
- Keep each pull request focused on a single problem or feature.

View File

@@ -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.
Feel free to try it using the following methods:
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
### 🍎 macOS Installation Notes
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
```bash
xattr -rd com.apple.quarantine /Applications/VidBee.app/
```
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
### 🌐 Browser Script (Quick Download)
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
![VidBee Browser Script](screenshots/browser-script.png)
**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
[📥 Download VidBee](https://vidbee.org/download/) | [📚 Documentation](https://docs.vidbee.org)
> [!IMPORTANT]
>
@@ -102,29 +61,14 @@ Automatically subscribe to RSS feeds and auto-download new videos in the backgro
## 🌐 Supported Sites
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
| Video Platforms | Audio & Other Platforms |
| :--------------- | :---------------------- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
> **💡 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).
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/)
## 🤝 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

View File

@@ -45,12 +45,13 @@
"**/*.js",
"**/*.jsx",
"**/*.json",
"!docs",
"!monkey/dist",
"!dist",
"!out",
"!build"
],
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
"experimentalScannerIgnores": ["docs/**", "monkey/dist/**", "dist/**", "out/**", "build/**"]
},
"vcs": {
"enabled": true,

63
build/after-pack.cjs Normal file
View File

@@ -0,0 +1,63 @@
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const BINARIES = [
'yt-dlp_macos',
path.join('ffmpeg', 'ffmpeg'),
path.join('ffmpeg', 'ffprobe'),
'deno'
]
const findAppBundle = (appOutDir) => {
const entries = fs.readdirSync(appOutDir)
const app = entries.find((entry) => entry.endsWith('.app'))
return app ? path.join(appOutDir, app) : null
}
const resolveSigningIdentity = () =>
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
const signBinary = (targetPath, entitlementsPath) => {
const identity = resolveSigningIdentity()
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
if (identity !== '-') {
args.push('--options', 'runtime', '--timestamp')
}
args.push(targetPath)
execFileSync('codesign', args, { stdio: 'inherit' })
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') {
return
}
const appBundle = findAppBundle(context.appOutDir)
if (!appBundle) {
console.warn('afterPack: No .app bundle found, skipping tool signing.')
return
}
const resourcesPath = path.join(
appBundle,
'Contents',
'Resources',
'app.asar.unpacked',
'resources'
)
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
for (const binary of BINARIES) {
const targetPath = path.join(resourcesPath, binary)
if (!fs.existsSync(targetPath)) {
console.warn(`afterPack: Missing ${binary}, skipping.`)
continue
}
console.log(`afterPack: Signing ${binary} with entitlements.`)
signBinary(targetPath, entitlementsPath)
}
}

View File

@@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

26
docs/.gitignore vendored Normal file
View 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
View 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
View 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
View 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.
![Browser cookies settings](/browser-cookies.png)
**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.
![Cookies file settings](/cookies-file.png)
**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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"title": "VidBee Docs",
"pages": ["index", "protocol", "cookies", "faq"]
}

149
docs/content/protocol.mdx Normal file
View 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/)

View File

@@ -0,0 +1,63 @@
---
title: Cookie 使用说明
description: 登录下载、受限内容与 Cookie 配置
---
Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或验证的内容,例如订阅内容、年龄限制或私密链接。
## 适用场景
- 需要账号登录才能观看或下载的内容。
- 年龄限制或地区限制页面。
- 仅对登录用户可见的私密链接或列表。
## VidBee 支持的两种方式
### 方式一:读取浏览器 Cookie
在 **设置** 中选择你的浏览器VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。
![浏览器 Cookie 设置](/browser-cookies.png)
**支持的浏览器(按平台差异显示):**
- **Windows仅支持 Firefox。其他浏览器无法读取 Cookie。**
- macOS全部支持。
- Linux全部支持。
**使用步骤:**
1. 打开 VidBee → 设置 → Cookie。
2. 选择浏览器并确认配置文件路径。
3. 返回下载页面,重新开始下载任务。
如果识别失败或路径无效,请手动选择实际的浏览器配置文件目录。
在 Windows 上,如果不是 Firefox请改用 cookies 文件方式。
### 方式二:导入 Cookies 文件
你也可以导入 **Netscape 格式** 的 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用。
![Cookies 文件设置](/cookies-file.png)
**使用步骤:**
1. 使用浏览器扩展导出 Netscape cookies 文件。
2. 打开 VidBee → 设置 → Cookies 文件,选择导出的文件。
3. 如需停用,可点击“清除”。
## 使用建议
- 优先使用浏览器读取方式,维护成本更低。
- 如果浏览器读取失败,再切换到 cookies 文件方式。
- cookies 文件会过期,账号状态变化时需要重新导出。
## 常见问题
- **提示配置文件路径无效**:请确认路径存在,并指向实际的浏览器配置文件目录。
- **下载仍提示需要登录**:确认当前浏览器已登录对应账号,并重新保存设置。
- **浏览器读取失败**:关闭浏览器后重试,或改用 cookies 文件方式。
## 隐私与安全
Cookie 等同于登录态,请妥善保管,不要共享或上传。若怀疑泄露,请及时在网站上退出登录并更新密码。

36
docs/content/zh/faq.mdx Normal file
View 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
View 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/)

View File

@@ -0,0 +1,4 @@
{
"title": "VidBee 文档",
"pages": ["index", "protocol", "cookies", "faq"]
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

5
docs/postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

101
docs/public/ICONS.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
docs/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
docs/public/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
docs/public/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

BIN
docs/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/public/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/public/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
docs/public/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
docs/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/public/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

46
docs/scripts/post-export.js Executable file
View 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
View 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
},
});

View 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,
},
};
}

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

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

View 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
View 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
View 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
View 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)}`,
};
});
}

View 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
View File

@@ -0,0 +1 @@
export { twMerge as cn } from 'tailwind-merge';

34
docs/src/lib/i18n.ts Normal file
View 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;
}

View 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
View 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}`;
}

View 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
View 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
View 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"
]
}

View File

@@ -2,17 +2,23 @@ appId: com.vidbee
productName: VidBee
directories:
buildResources: build
afterPack: build/after-pack.cjs
protocols:
- name: VidBee
schemes:
- vidbee
files:
- '!**/.vscode/*'
- '!**/.context/**'
- '!**/.github/**'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!extension/**'
- '!monkey/**'
- '!screenshots/**'
asarUnpack:
- resources/**
win:
@@ -23,9 +29,10 @@ nsis:
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
identity: null
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
notarize: false
notarize: true
artifactName: ${name}-${version}-${arch}.${ext}
target:
- target: zip

View File

@@ -24,3 +24,6 @@ web-ext.config.ts
*.njsproj
*.sln
*.sw?
.env
.env.*

View File

@@ -1,3 +1,54 @@
# WXT + React
# VidBee Video Downloader Extension
This template should help get you started developing with React in WXT.
VidBee Video Downloader is a lightweight browser companion for the VidBee desktop app. It detects the video on your current tab, shows the available formats, and hands the URL to VidBee so the download happens in the desktop app instead of your browser.
## Why install it?
- **One-click handoff:** Send the current video page to VidBee without copy-pasting links.
- **See formats before downloading:** Preview resolutions, file sizes, and audio-only options in the popup.
- **More reliable downloads:** VidBee handles large files and multi-format downloads better than most browsers.
- **Works on 1,000+ sites:** The extension uses the same site support as the VidBee app.
## What it does
1. Reads the active tab URL when you open the popup.
2. Asks the VidBee desktop app (running locally) to analyze the video.
3. Displays the formats it finds and lets you open VidBee to download.
## Requirements
- VidBee desktop app installed.
- VidBee running while you use the extension.
## How to use
1. Open a supported video page.
2. Click the VidBee extension icon.
3. Review available formats.
4. Click **Download with VidBee** to start the download in the desktop app.
## Development
```bash
pnpm install
pnpm dev
```
Build or package:
```bash
pnpm build
pnpm zip
```
Firefox builds:
```bash
pnpm dev:firefox
pnpm build:firefox
pnpm zip:firefox
```
## Notes on privacy
The extension only sends the current tab URL to the local VidBee app on `127.0.0.1` and stores temporary results in browser storage for faster reloads.

View File

@@ -208,16 +208,42 @@ h1 {
gap: 12px;
}
.troubleshoot-box {
background: var(--border);
.error-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--fg);
}
.error-description {
font-size: 12px;
color: var(--fg-secondary);
margin: 0;
line-height: 1.5;
}
.action-grid {
display: grid;
gap: 12px;
}
.action-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 8px;
background: #fafafa;
}
.troubleshoot-title {
.action-title {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
@@ -226,31 +252,31 @@ h1 {
letter-spacing: 0.05em;
}
.troubleshoot-actions {
display: flex;
align-items: center;
gap: 8px;
.action-text {
font-size: 13px;
color: var(--fg);
margin: 0;
line-height: 1.5;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: none;
.secondary-button {
border: 1px solid var(--fg);
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
font-size: inherit;
background: transparent;
color: var(--fg);
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
}
.link-button:hover {
text-decoration: underline;
}
.divider {
color: var(--fg-secondary);
.secondary-button:hover {
background: var(--border);
}

View File

@@ -265,14 +265,49 @@ function App() {
return result
}, [groupedFormats.video])
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const clientLaunchDelayMs = 2000
const openClientApp = async () => {
window.location.href = 'vidbee://'
await wait(clientLaunchDelayMs)
}
const handleOpenClient = () => {
if (!currentUrl) return
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const handleOpenClientAndRetry = async () => {
await openClientApp()
setRetryTrigger((count) => count + 1)
}
const handleRetry = () => {
setRetryTrigger((count) => count + 1)
}
const isInvalidPageError = error === 'Please open a valid video page first.'
const isClientConnectionError = Boolean(error?.includes('Client connection failed'))
const errorTitle = isInvalidPageError
? 'Open a video page'
: isClientConnectionError
? 'Connect the VidBee app'
: 'Something went wrong'
const errorDescription = isInvalidPageError
? 'Navigate to a supported video page, then try again.'
: isClientConnectionError
? 'The extension needs the VidBee desktop app to be running.'
: 'Try again in a moment.'
const renderStatus = () => {
// if (loading) return null
if (loading)
return (
<span className="status-indicator">
<div className="status-dot loading" /> Working
</span>
)
if (error)
return (
<span className="status-indicator">
@@ -307,31 +342,54 @@ function App() {
{!loading && error && (
<div className="error-container">
<div className="error-banner">{error}</div>
<div className="troubleshoot-box">
<p className="troubleshoot-title">Having trouble?</p>
<div className="troubleshoot-actions">
<button
type="button"
className="link-button"
onClick={() => {
window.location.href = 'vidbee://'
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
}}
>
Open Client
</button>
<span className="divider"></span>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="link-button"
>
Download
</a>
</div>
<div className="error-header">
<h2 className="error-title">{errorTitle}</h2>
<p className="error-description">{errorDescription}</p>
</div>
<div className="error-banner">{error}</div>
{isClientConnectionError ? (
<div className="action-grid">
<div className="action-card">
<p className="action-title">Client installed</p>
<p className="action-text">
Start VidBee and keep it running, then we will retry automatically.
</p>
<button
type="button"
className="secondary-button"
onClick={handleOpenClientAndRetry}
>
Open Client
</button>
</div>
<div className="action-card">
<p className="action-title">Need the app?</p>
<p className="action-text">
Download VidBee once, install it, then come back here to try again.
</p>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="secondary-button"
>
Download VidBee
</a>
</div>
</div>
) : (
<div className="action-card">
<p className="action-title">Try again</p>
<p className="action-text">
{isInvalidPageError
? 'Open a supported video page, then retry.'
: 'Retry after a moment.'}
</p>
<button type="button" className="secondary-button" onClick={handleRetry}>
Retry
</button>
</div>
)}
</div>
)}

View File

@@ -1,8 +1,8 @@
{
"name": "wxt-react-starter",
"name": "vidbee-extension",
"description": "manifest.json description",
"private": true,
"version": "0.0.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "wxt",

View File

@@ -1,4 +1,10 @@
{
"extensionName": {
"message": "VidBee Video Downloader"
},
"extensionDescription": {
"message": "Download videos from over 1,000 websites with VidBee."
},
"downloadWithVidBee": {
"message": "Download with VidBee"
},

View File

@@ -4,6 +4,8 @@ import { defineConfig } from 'wxt'
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
name: '__MSG_extensionName__',
description: '__MSG_extensionDescription__',
default_locale: 'en',
host_permissions: ['http://127.0.0.1/*'],
permissions: ['activeTab', 'storage']

View File

@@ -1,12 +1,13 @@
{
"name": "vidbee",
"version": "1.1.6",
"version": "1.2.1",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
"homepage": "https://github.com/nexmoe/vidbee",
"scripts": {
"check": "biome check --write . && pnpm run typecheck",
"check": "pnpm run check:i18n && biome check --write . && pnpm run typecheck",
"check:i18n": "node scripts/check-locales.js",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
@@ -35,6 +36,7 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",

34
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@radix-ui/react-progress':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1083,6 +1086,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-radio-group@1.3.8':
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -4586,6 +4602,24 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3

View File

@@ -6,6 +6,9 @@ ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
ffmpeg/
ffprobe
ffprobe.exe
deno.exe
deno

View File

@@ -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"
```
## 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
1. **Windows**: `ffmpeg.exe`
2. **macOS**: `ffmpeg_macos`
3. **Linux**: `ffmpeg_linux`
1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe`
2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
### How to Download
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
- **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-*.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 both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`.
### Note
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
- 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 path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`.
- File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe)
## JS Runtime (Deno)

100
scripts/check-locales.js Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
const fs = require('node:fs')
const path = require('node:path')
const localesDir = path.join(__dirname, '..', 'src', 'renderer', 'src', 'locales')
const baseLocaleFile = 'en.json'
const readJson = (filePath) => {
try {
const raw = fs.readFileSync(filePath, 'utf8')
return JSON.parse(raw)
} catch (error) {
console.error(`ERROR: Failed to read ${filePath}`)
console.error(String(error))
process.exit(1)
}
}
const collectLeafKeys = (value, prefix = '', keys = new Set()) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, child] of Object.entries(value)) {
const next = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) {
collectLeafKeys(child, next, keys)
} else {
keys.add(next)
}
}
return keys
}
if (prefix) {
keys.add(prefix)
}
return keys
}
if (!fs.existsSync(localesDir)) {
console.error(`ERROR: Locales directory not found: ${localesDir}`)
process.exit(1)
}
const localeFiles = fs
.readdirSync(localesDir)
.filter((file) => file.endsWith('.json'))
.sort()
if (!localeFiles.includes(baseLocaleFile)) {
console.error(`ERROR: Base locale file not found: ${baseLocaleFile}`)
process.exit(1)
}
const baseLocalePath = path.join(localesDir, baseLocaleFile)
const baseLocaleData = readJson(baseLocalePath)
const baseKeys = collectLeafKeys(baseLocaleData)
let hasMissing = false
let hasExtra = false
for (const file of localeFiles) {
if (file === baseLocaleFile) {
continue
}
const localePath = path.join(localesDir, file)
const localeData = readJson(localePath)
const localeKeys = collectLeafKeys(localeData)
const missing = [...baseKeys].filter((key) => !localeKeys.has(key))
const extra = [...localeKeys].filter((key) => !baseKeys.has(key))
if (missing.length > 0) {
hasMissing = true
console.error(`ERROR: Missing keys in ${file}`)
for (const key of missing) {
console.error(` - ${key}`)
}
}
if (extra.length > 0) {
hasExtra = true
console.warn(`WARN: Extra keys in ${file}`)
for (const key of extra) {
console.warn(` - ${key}`)
}
}
}
if (hasMissing) {
process.exit(1)
}
if (hasExtra) {
console.log('INFO: No missing keys, but extra keys were found.')
process.exit(0)
}
console.log('OK: All locale files include every key from en.json.')

View File

@@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) {
const binaries = [
{
label: 'yt-dlp',
filenameMap: {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
paths: {
win: ['yt-dlp.exe'],
mac: ['yt-dlp_macos'],
linux: ['yt-dlp_linux']
},
help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
@@ -34,10 +34,23 @@ const binaries = [
},
{
label: 'ffmpeg',
filenameMap: {
win: 'ffmpeg.exe',
mac: 'ffmpeg_macos',
linux: 'ffmpeg_linux'
paths: {
win: ['ffmpeg/ffmpeg.exe'],
mac: ['ffmpeg/ffmpeg'],
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: {
win: 'https://ffmpeg.org/download.html',
@@ -47,10 +60,10 @@ const binaries = [
},
{
label: 'deno',
filenameMap: {
win: 'deno.exe',
mac: 'deno',
linux: 'deno'
paths: {
win: ['deno.exe'],
mac: ['deno'],
linux: ['deno']
},
help: {
default: 'https://github.com/denoland/deno/releases/latest'
@@ -61,12 +74,15 @@ const binaries = [
let hasMissingBinary = false
for (const binary of binaries) {
const filename = binary.filenameMap[platform]
const binaryPath = path.join(__dirname, '..', 'resources', filename)
const candidates = binary.paths[platform] || []
const found = candidates.find((filename) =>
fs.existsSync(path.join(__dirname, '..', 'resources', filename))
)
if (!fs.existsSync(binaryPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
if (!found) {
const expected = candidates.length ? candidates.join(' or ') : binary.label
console.error(`❌ Error: resources/${expected} not found!`)
console.error(`Please download ${binary.label} to the resources/ directory first.`)
const help =
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
if (help) {
@@ -74,7 +90,7 @@ for (const binary of binaries) {
}
hasMissingBinary = true
} else {
console.log(`${filename} found in resources/ directory`)
console.log(`${binary.label} found: resources/${found}`)
}
}

View File

@@ -8,12 +8,13 @@
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { execSync } = require('node:child_process')
const { execSync, spawnSync } = require('node:child_process')
const https = require('node:https')
const http = require('node:http')
// Configuration
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 DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
const GITHUB_TOKEN =
@@ -29,11 +30,13 @@ const PLATFORM_CONFIG = {
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',
output: 'ffmpeg.exe',
ffprobeOutput: 'ffprobe.exe',
extract: 'unzip',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /win64.*gpl.*\.zip$/i,
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
binaryName: 'ffmpeg.exe'
}
}
@@ -46,9 +49,11 @@ const PLATFORM_CONFIG = {
ffmpeg: {
// For development, download only the architecture matching current system
arm64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
@@ -56,9 +61,11 @@ const PLATFORM_CONFIG = {
}
},
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',
output: 'ffmpeg_macos',
ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
@@ -75,11 +82,13 @@ const PLATFORM_CONFIG = {
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'tar',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
}
@@ -125,24 +134,43 @@ function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
let downloadedBytes = 0
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
safeUnlink(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
const redirectUrl = response.headers.location
if (!redirectUrl) {
return reject(new Error(`Redirect without location for ${url}`))
}
log(`Redirected to ${redirectUrl}`, 'info')
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
}
const contentLength = response.headers['content-length']
if (response.statusCode !== 200) {
file.close()
safeUnlink(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
return reject(
new Error(
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
)
)
}
response.on('data', (chunk) => {
downloadedBytes += chunk.length
})
response.pipe(file)
file.on('finish', () => {
file.close()
log(
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
downloadedBytes ? 'success' : 'warn'
)
resolve()
})
})
@@ -154,6 +182,7 @@ function downloadFile(url, dest) {
request.on('error', (err) => {
file.close()
safeUnlink(dest)
log(`Download error for ${url}: ${err.message}`, 'error')
reject(err)
})
})
@@ -163,6 +192,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
await downloadFile(url, dest)
return
} catch (error) {
@@ -170,7 +200,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
await new Promise((resolve) => setTimeout(resolve, backoff))
}
}
@@ -298,6 +328,43 @@ function fileExists(filePath) {
return fs.existsSync(filePath)
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) {
return 'unknown size'
}
if (bytes >= 1024 * 1024) {
return `${Math.round(bytes / (1024 * 1024))} MB`
}
return `${Math.round(bytes / 1024)} KB`
}
function checkBinary(filePath, args, label, options = {}) {
const timeoutMs =
typeof options.timeoutMs === 'number'
? options.timeoutMs
: os.platform() === 'win32'
? 20000
: 8000
const result = spawnSync(filePath, args, {
encoding: 'utf8',
timeout: timeoutMs,
windowsHide: true
})
if (result.error) {
return { ok: false, message: result.error.message, code: result.error.code }
}
if (result.status !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
return { ok: false, message: output || `exit code ${result.status}` }
}
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
}
function getDenoAssetName(platform, arch) {
if (platform === 'win32') {
if (arch === 'arm64') {
@@ -330,6 +397,10 @@ async function downloadYtDlp(config) {
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
}
log(`${output} already exists, skipping download`, 'info')
return
}
@@ -342,6 +413,11 @@ async function downloadYtDlp(config) {
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
@@ -352,19 +428,43 @@ async function downloadYtDlp(config) {
}
async function downloadFfmpegWindows(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
const {
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)) {
log(`${output} already exists, skipping download`, 'info')
return
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? 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(`Downloading ffmpeg for Windows...`, 'download')
ensureDir(FFMPEG_DIR)
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) {
try {
@@ -375,6 +475,10 @@ async function downloadFfmpegWindows(config) {
if (inferred) {
innerPath = inferred
}
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
@@ -392,7 +496,24 @@ async function downloadFfmpegWindows(config) {
}
fs.copyFileSync(sourcePath, outputPath)
log(`Downloaded ${output} successfully`, 'success')
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')
if (!validation.ok) {
if (validation.code === 'ETIMEDOUT') {
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')
}
// Cleanup
fs.unlinkSync(tempZip)
@@ -412,15 +533,38 @@ async function downloadFfmpegMac(config) {
throw new Error(`Unsupported architecture: ${arch}`)
}
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
const {
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)) {
log(`${output} already exists, skipping download`, 'info')
return
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? 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(`Downloading ffmpeg for macOS (${arch})...`, 'download')
ensureDir(FFMPEG_DIR)
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
@@ -448,6 +592,19 @@ async function downloadFfmpegMac(config) {
fs.copyFileSync(sourcePath, 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')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
@@ -461,19 +618,43 @@ async function downloadFfmpegMac(config) {
}
async function downloadFfmpegLinux(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
const {
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)) {
log(`${output} already exists, skipping download`, 'info')
return
const ffmpegExists = fileExists(outputPath)
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
if (ffmpegExists && ffprobeExists) {
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
const ffprobeValidation = ffprobeOutputPath
? 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(`Downloading ffmpeg for Linux...`, 'download')
ensureDir(FFMPEG_DIR)
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) {
try {
@@ -484,6 +665,10 @@ async function downloadFfmpegLinux(config) {
if (inferred) {
innerPath = inferred
}
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
@@ -502,6 +687,19 @@ async function downloadFfmpegLinux(config) {
fs.copyFileSync(sourcePath, 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')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
}
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
@@ -528,6 +726,10 @@ async function downloadDenoRuntime() {
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
}
log(`${outputName} already exists, skipping download`, 'info')
return
}
@@ -549,6 +751,11 @@ async function downloadDenoRuntime() {
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
const validation = checkBinary(outputPath, ['--version'], 'deno')
if (!validation.ok) {
safeUnlink(outputPath)
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
}
log(`Downloaded ${outputName} successfully`, 'success')
fs.unlinkSync(tempZip)

View File

@@ -20,15 +20,21 @@ export const sanitizeFilenameTemplate = (template: string): string => {
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
const format = options.format
const audioFormat = options.audioFormat
const audioFormatIds = (options.audioFormatIds ?? []).filter((id) => id.trim() !== '')
if (format && audioFormat === '') {
return format
}
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
if (format && (format.includes('/') || format.includes('+') || format.includes('['))) {
return format
}
if (audioFormatIds.length > 0) {
const baseVideo = format && format !== 'best' ? format : 'bestvideo*'
return `${baseVideo}+${audioFormatIds.join('+')}`
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
@@ -62,13 +68,22 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
return format
}
const isBilibiliUrl = (url: string): boolean => {
try {
const host = new URL(url).hostname.toLowerCase()
return host.includes('bilibili.com') || host.includes('b23.tv') || host.includes('bili.tv')
} catch {
return false
}
}
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings,
jsRuntimeArgs: string[] = []
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
const args: string[] = ['--no-playlist', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
@@ -76,16 +91,19 @@ export const buildDownloadArgs = (
// Format selection
if (options.type === 'video') {
const formatSelector = resolveVideoFormatSelector(options)
args.push('-f', formatSelector)
if (formatSelector) {
args.push('-f', formatSelector)
}
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
args.push('--audio-multistreams')
} else if (formatSelector.includes('mergeall')) {
args.push('--audio-multistreams')
}
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
// based on codec compatibility. Forcing MP4 can cause failures
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
} else if (options.type === 'audio') {
args.push('-f', resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
@@ -95,10 +113,28 @@ export const buildDownloadArgs = (
args.push('--download-sections', `*${start}-${end || ''}`)
}
const embedSubs = settings.embedSubs
const embedMetadata = settings.embedMetadata
const embedChapters = settings.embedChapters
const hasSubtitleAuth =
(settings.browserForCookies && settings.browserForCookies !== 'none') ||
Boolean(settings.cookiesPath?.trim())
const shouldAttemptSubtitles = !isBilibiliUrl(options.url) || hasSubtitleAuth
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
if (shouldAttemptSubtitles) {
if (embedSubs) {
args.push('--sub-langs', 'all')
} else {
args.push('--write-subs')
}
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
} else {
args.push('--no-embed-subs')
}
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
// Output path with proper encoding handling
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
@@ -109,8 +145,8 @@ export const buildDownloadArgs = (
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
args.push('-o', outputTemplate)
// Add options for better filename handling
args.push('--no-part')
// Allow resume support across restarts
args.push('--continue')
args.push('--no-playlist-reverse')
if (process.platform === 'win32') {

View File

@@ -194,7 +194,7 @@ export const resolveSelectedFormat = (
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
if (options.type === 'audio') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&

View File

@@ -253,6 +253,10 @@ function setupDownloadEvents(): void {
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) => {
mainWindow?.webContents.send('download:completed', id)
})
@@ -364,12 +368,6 @@ function initAutoUpdater(): void {
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
version: info.version
})
}
})
log.info('Auto-updater initialized successfully')
@@ -455,14 +453,20 @@ app.whenReady().then(async () => {
}
// Initialize yt-dlp
let ytdlpReady = false
try {
log.info('Initializing yt-dlp...')
await ytdlpManager.initialize()
ytdlpReady = true
log.info('yt-dlp initialized successfully')
} catch (error) {
log.error('Failed to initialize yt-dlp:', error)
}
if (ytdlpReady) {
downloadEngine.restoreActiveDownloads()
}
await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon'))
@@ -480,14 +484,27 @@ app.whenReady().then(async () => {
handleDeepLinkArgv(process.argv)
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
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
createWindow()
})
})
app.on('before-quit', () => {
isQuitting = true
downloadEngine.flushDownloadSession()
})
// Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -16,6 +16,37 @@ class AppService extends IpcService {
return os.platform()
}
@IpcMethod()
getOsVersion(_context: IpcContext): string {
const platform = os.platform()
const platformLabel =
platform === 'darwin'
? 'macOS'
: platform === 'win32'
? 'Windows'
: platform === 'linux'
? 'Linux'
: platform
const systemVersion =
typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'
? (process as { getSystemVersion: () => string }).getSystemVersion()
: typeof os.version === 'function'
? os.version()
: os.release()
if (platform === 'win32') {
const buildToken = systemVersion.split('.').at(-1) ?? ''
const buildNumber = Number.parseInt(buildToken, 10)
const windowsName =
Number.isFinite(buildNumber) && buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
return Number.isFinite(buildNumber)
? `${windowsName} (build ${buildNumber})`
: `${platformLabel} ${systemVersion}`.trim()
}
return `${platformLabel} ${systemVersion}`.trim()
}
@IpcMethod()
quit(_context: IpcContext): void {
app.quit()

View File

@@ -5,7 +5,8 @@ import type {
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../../shared/types'
import { downloadEngine } from '../../lib/download-engine'
@@ -17,6 +18,14 @@ class DownloadService extends IpcService {
return downloadEngine.getVideoInfo(url)
}
@IpcMethod()
async getVideoInfoWithCommand(
_context: IpcContext,
url: string
): Promise<VideoInfoCommandResult> {
return downloadEngine.getVideoInfoWithCommand(url)
}
@IpcMethod()
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
return downloadEngine.getPlaylistInfo(url)
@@ -37,6 +46,11 @@ class DownloadService extends IpcService {
return downloadEngine.getQueueStatus()
}
@IpcMethod()
getActiveDownloads(_context: IpcContext): DownloadItem[] {
return downloadEngine.getActiveDownloads()
}
@IpcMethod()
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
downloadEngine.updateDownloadInfo(id, updates)

View File

@@ -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()
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
try {

View File

@@ -6,7 +6,10 @@ import type {
SubscriptionRule,
SubscriptionUpdatePayload
} 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 { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
@@ -113,6 +116,10 @@ class SubscriptionService extends IpcService {
options: CreateSubscriptionOptions
): Promise<SubscriptionRule> {
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 defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
const payload: SubscriptionCreatePayload = {
@@ -141,6 +148,12 @@ class SubscriptionService extends IpcService {
id: string,
updates: SubscriptionUpdatePayload
): 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 }
if (typeof normalized.namingTemplate === 'string') {
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)

View File

@@ -15,6 +15,8 @@ export const downloadHistoryTable = sqliteTable('download_history', {
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
error: text('error'),
ytDlpCommand: text('yt_dlp_command'),
ytDlpLog: text('yt_dlp_log'),
description: text('description'),
channel: text('channel'),
uploader: text('uploader'),

View File

@@ -11,7 +11,8 @@ import type {
PlaylistDownloadResult,
PlaylistInfo,
VideoFormat,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../shared/types'
import {
buildDownloadArgs,
@@ -27,6 +28,11 @@ import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger'
import { resolvePathWithHome } from '../utils/path-helpers'
import { DownloadQueue } from './download-queue'
import {
type DownloadSessionItem,
loadDownloadSession,
saveDownloadSession
} from './download-session-store'
import { ffmpegManager } from './ffmpeg-manager'
import { historyManager } from './history-manager'
import { ytdlpManager } from './ytdlp-manager'
@@ -49,6 +55,8 @@ const formatYtDlpCommand = (args: string[]): string => {
return `yt-dlp ${quoted.join(' ')}`
}
const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath)
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
@@ -80,6 +88,59 @@ const isLikelyChannelUrl = (url: string): boolean => {
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
}
const clampPercent = (value?: number): number => {
const normalized = typeof value === 'number' ? value : 0
if (Number.isNaN(normalized)) {
return 0
}
return Math.min(100, Math.max(0, normalized))
}
const estimateProgressParts = (options: DownloadOptions): number => {
if (options.type === 'audio') {
return 1
}
const audioFormatCount = options.audioFormatIds?.filter((id) => id.trim() !== '').length ?? 0
if (audioFormatCount > 0) {
return 1 + audioFormatCount
}
const selector = options.format?.trim()
if (!selector) {
return 2
}
const primary = selector.split('/')[0]?.trim()
if (!primary) {
return 2
}
const parts = primary
.split('+')
.map((part) => part.trim())
.filter((part) => part !== '')
if (parts.length <= 1) {
return 1
}
if (parts.some((part) => part === 'none')) {
return 1
}
return parts.length
}
const isMuxedFormat = (format?: VideoFormat): boolean => {
if (!format) {
return false
}
const hasVideo = !!format.vcodec && format.vcodec !== 'none'
const hasAudio = !!format.acodec && format.acodec !== 'none'
return hasVideo && hasAudio
}
const resolveAutoPlaylistDownloadPath = (
basePath: string,
info: PlaylistInfo,
@@ -165,9 +226,49 @@ const appendJsRuntimeArgs = (args: string[]): void => {
}
}
const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsManager.getAll>) => {
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
return args
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue
private sessionPersistTimer: NodeJS.Timeout | null = null
private sessionRestored = false
constructor() {
super()
@@ -177,45 +278,17 @@ class DownloadEngine extends EventEmitter {
this.queue.on('start-download', async (item) => {
await this.executeDownload(item.id, item.options)
})
this.queue.on('queue-updated', () => {
this.scheduleSessionPersist()
})
}
async getVideoInfo(url: string): Promise<VideoInfo> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
const args = buildVideoInfoArgs(url, settings)
return new Promise((resolve, reject) => {
const process = ytdlp.exec(args)
@@ -281,6 +354,90 @@ class DownloadEngine extends EventEmitter {
})
}
async getVideoInfoWithCommand(url: string): Promise<VideoInfoCommandResult> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = buildVideoInfoArgs(url, settings)
const ytDlpCommand = formatYtDlpCommand(args)
return new Promise((resolve) => {
let settled = false
const resolveOnce = (payload: VideoInfoCommandResult) => {
if (settled) {
return
}
settled = true
resolve(payload)
}
const process = ytdlp.exec(args)
let stdout = ''
let stderr = ''
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
stdout += data.toString()
})
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
})
process.on('close', (code) => {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
// Calculate estimated file size for formats missing filesize information
// Using tbr (total bitrate in kbps) and duration (in seconds)
// Formula: (tbr * 1000) / 8 * duration = size in bytes
if (info.formats && Array.isArray(info.formats) && info.duration) {
const duration = info.duration
for (const format of info.formats) {
if (
!format.filesize &&
!format.filesize_approx &&
format.tbr &&
typeof format.tbr === 'number' &&
duration > 0
) {
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
format.filesize_approx = estimatedSize
}
}
}
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolveOnce({ info, ytDlpCommand })
} catch (error) {
scopedLoggers.download.error('Failed to parse video info for:', url, error)
resolveOnce({
ytDlpCommand,
error: `Failed to parse video info: ${error instanceof Error ? error.message : error}`
})
}
} else {
scopedLoggers.download.error(
'Failed to fetch video info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' })
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error for:', url, error)
resolveOnce({
ytDlpCommand,
error: error instanceof Error ? error.message : 'Failed to fetch video info'
})
})
})
}
async getPlaylistInfo(url: string): Promise<PlaylistInfo> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
@@ -471,16 +628,34 @@ class DownloadEngine extends EventEmitter {
`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
for (const entry of selectedEntries) {
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 = {
url: entry.url,
type: options.type,
format: options.format,
audioFormat: options.type === 'audio' ? options.format : undefined,
customDownloadPath: resolvedDownloadPath
customDownloadPath: resolvedDownloadPath,
customFilenameTemplate
}
const createdAt = Date.now()
@@ -591,6 +766,49 @@ class DownloadEngine extends EventEmitter {
let actualFormat: string | null = null
let videoInfo: VideoInfo | undefined
let lastKnownOutputPath: string | undefined
let totalParts = estimateProgressParts(options)
let completedParts = 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
try {
@@ -604,6 +822,14 @@ class DownloadEngine extends EventEmitter {
actualFormat = selectedFormat.ext || actualFormat
}
if (
options.type === 'video' &&
(!options.audioFormatIds || options.audioFormatIds.length === 0) &&
isMuxedFormat(selectedFormat)
) {
totalParts = 1
}
this.updateDownloadInfo(id, {
title: info.title,
thumbnail: info.thumbnail,
@@ -742,10 +968,13 @@ class DownloadEngine extends EventEmitter {
return
}
args.push('--ffmpeg-location', ffmpegPath)
const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)
args.push('--ffmpeg-location', ffmpegLocation)
args.push(urlArg)
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
const ytDlpCommand = formatYtDlpCommand(args)
this.updateDownloadInfo(id, { ytDlpCommand })
scopedLoggers.download.info('yt-dlp command:', ytDlpCommand)
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
@@ -754,6 +983,16 @@ class DownloadEngine extends EventEmitter {
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.upsertHistoryEntry(id, options, {
@@ -785,13 +1024,33 @@ class DownloadEngine extends EventEmitter {
: downloadedBytes
}
const normalizedPercent = clampPercent(progress.percent)
if (
totalParts > 1 &&
lastPercent >= 90 &&
normalizedPercent <= 10 &&
completedParts < totalParts - 1
) {
completedParts += 1
}
lastPercent = normalizedPercent
const mergedPercent =
totalParts > 1
? ((completedParts + normalizedPercent / 100) / totalParts) * 100
: normalizedPercent
const downloadProgress: DownloadProgress = {
percent: progress.percent || 0,
percent: Math.min(100, mergedPercent),
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
}
this.queue.updateItemInfo(id, {
progress: downloadProgress,
speed: downloadProgress.currentSpeed || ''
})
this.scheduleSessionPersist()
this.emit('download-progress', id, downloadProgress)
}
)
@@ -831,6 +1090,7 @@ class DownloadEngine extends EventEmitter {
// Handle completion
ytdlpProcess.on('close', async (code: number | null) => {
flushLogUpdate()
this.activeDownloads.delete(id)
this.queue.downloadCompleted(id)
@@ -844,7 +1104,8 @@ class DownloadEngine extends EventEmitter {
// based on codec compatibility, so we should use actualFormat when available
let extension: string
if (options.type === 'audio') {
extension = options.extractFormat || 'mp3'
// Use format extension from yt-dlp output (actualFormat contains the extension)
extension = actualFormat || 'm4a'
} else if (willMerge) {
// For merged files, yt-dlp auto-selects format (mkv/webm/mp4)
// Use actualFormat if available, otherwise default to mkv (most compatible)
@@ -960,6 +1221,7 @@ class DownloadEngine extends EventEmitter {
// Handle errors
ytdlpProcess.on('error', (error: Error) => {
flushLogUpdate()
scopedLoggers.download.error('Download process error for ID:', id, error)
this.activeDownloads.delete(id)
this.queue.downloadCompleted(id)
@@ -1005,6 +1267,72 @@ class DownloadEngine extends EventEmitter {
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 {
this.queue.updateItemInfo(id, updates)
@@ -1066,6 +1394,12 @@ class DownloadEngine extends EventEmitter {
if (updates.error !== undefined) {
historyUpdates.error = updates.error
}
if (updates.ytDlpCommand !== undefined) {
historyUpdates.ytDlpCommand = updates.ytDlpCommand
}
if (updates.ytDlpLog !== undefined) {
historyUpdates.ytDlpLog = updates.ytDlpLog
}
if (updates.savedFileName !== undefined) {
historyUpdates.savedFileName = updates.savedFileName
}
@@ -1073,6 +1407,37 @@ class DownloadEngine extends EventEmitter {
if (Object.keys(historyUpdates).length > 0) {
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(
@@ -1083,7 +1448,7 @@ class DownloadEngine extends EventEmitter {
): void {
// Get the download item from the queue to get additional info
const completedDownload = this.queue.getCompletedDownload(id)
scopedLoggers.download.info('Completed download:', completedDownload)
// scopedLoggers.download.info('Completed download:', completedDownload)
const completedAt = Date.now()
this.upsertHistoryEntry(id, options, {
@@ -1130,6 +1495,8 @@ class DownloadEngine extends EventEmitter {
downloadedAt: updates.downloadedAt ?? Date.now(),
completedAt: updates.completedAt,
error: updates.error,
ytDlpCommand: updates.ytDlpCommand,
ytDlpLog: updates.ytDlpLog,
description: updates.description,
channel: updates.channel,
uploader: updates.uploader,

View File

@@ -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 {
return this.activeDownloads.has(id)
}

View 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)
}
}

View File

@@ -28,46 +28,58 @@ class FfmpegManager {
private async findFfmpegBinary(): Promise<string> {
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)) {
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
return process.env.FFMPEG_PATH
const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => {
const ffmpegPath = path.join(dirPath, ffmpegFileName)
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') {
resourceCandidates.push('ffmpeg.exe')
} else if (platform === 'darwin') {
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
} else {
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
const envPath = process.env.FFMPEG_PATH
if (envPath) {
if (!fs.existsSync(envPath)) {
throw new Error(
'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.'
)
}
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()
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
scopedLoggers.engine.warn(
'Failed to set executable permission on ffmpeg binary:',
error
)
}
}
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
return fullPath
}
const bundledDir = path.join(resourcesPath, 'ffmpeg')
const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg')
if (bundledResolved) {
return bundledResolved
}
if (platform === 'darwin') {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for (const candidate of commonPaths) {
if (fs.existsSync(candidate)) {
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
return candidate
const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin']
for (const candidate of commonDirs) {
const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg')
if (resolved) {
return resolved
}
}
}
@@ -76,8 +88,10 @@ class FfmpegManager {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
return systemPath
const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')
if (resolved) {
return resolved
}
}
} catch (_error) {
// Ignore error and continue
@@ -88,8 +102,10 @@ class FfmpegManager {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system ffmpeg:', output)
return output
const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')
if (resolved) {
return resolved
}
}
} catch (_error) {
// Ignore error and continue
@@ -97,7 +113,7 @@ class FfmpegManager {
}
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.'
)
}
}

View File

@@ -36,6 +36,8 @@ const createDownloadHistoryTableSql = sql`
completed_at INTEGER,
sort_key INTEGER NOT NULL,
error TEXT,
yt_dlp_command TEXT,
yt_dlp_log TEXT,
description TEXT,
channel TEXT,
uploader TEXT,
@@ -218,15 +220,53 @@ class HistoryManager {
}
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
if (needsRebuild) {
const requiredColumns = ['yt_dlp_command', 'yt_dlp_log']
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
const missingRequired = requiredColumns.filter(
(columnName) => !columns.some((column) => column.name === columnName)
)
if (hasDeprecated) {
this.rebuildDownloadHistoryTable()
return
}
if (missingRequired.length > 0) {
this.addMissingColumns(missingRequired)
}
} catch (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 {
const database = this.getDatabase()
logger.info('history-db migrating legacy payload schema to structured columns')
@@ -387,6 +427,8 @@ class HistoryManager {
completedAt: item.completedAt ?? null,
sortKey: item.completedAt ?? item.downloadedAt,
error: item.error ?? null,
ytDlpCommand: item.ytDlpCommand ?? null,
ytDlpLog: item.ytDlpLog ?? null,
description: item.description ?? null,
channel: item.channel ?? null,
uploader: item.uploader ?? null,
@@ -433,6 +475,8 @@ class HistoryManager {
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined,
error: row.error ?? undefined,
ytDlpCommand: row.ytDlpCommand ?? undefined,
ytDlpLog: row.ytDlpLog ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,

View File

@@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter {
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 {
const timestamp = Date.now()
const keywords = sanitizeList(payload.keywords)
@@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter {
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 {
if (!this.sqlite) {
return

View File

@@ -4,6 +4,10 @@ import log from 'electron-log/main'
import Parser from 'rss-parser'
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
import {
buildAudioFormatPreference,
buildVideoFormatPreference
} from '../../shared/utils/format-preferences'
import { settingsManager } from '../settings'
import { downloadEngine } from './download-engine'
import { historyManager } from './history-manager'
@@ -481,6 +485,11 @@ export class SubscriptionScheduler extends EventEmitter {
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
const namingTemplate =
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
const downloadType = settings.oneClickDownloadType ?? 'video'
const formatPreference =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
ensureDirectoryExists(downloadDirectory)
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
@@ -488,7 +497,8 @@ export class SubscriptionScheduler extends EventEmitter {
try {
downloadEngine.startDownload(downloadId, {
url,
type: 'video',
type: downloadType,
format: formatPreference,
customDownloadPath: downloadDirectory,
customFilenameTemplate: namingTemplate,
tags,

View File

@@ -20,9 +20,7 @@ const ensureDirectoryExists = (dir: string) => {
}
const resolveDefaultDownloadPath = () => {
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
ensureDirectoryExists(downloadDir)
return downloadDir
return path.join(os.homedir(), 'Downloads', 'VidBee')
}
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
@@ -75,7 +73,6 @@ class SettingsManager {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH
})
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
}
private ensureDownloadDirectory(): void {
@@ -88,7 +85,6 @@ class SettingsManager {
if (normalizedDownloadPath !== currentPath) {
this.store.set('downloadPath', normalizedDownloadPath)
}
ensureDirectoryExists(normalizedDownloadPath)
} catch (error) {
scopedLoggers.system.error('Failed to verify download directory:', error)
}

View File

@@ -5,7 +5,7 @@ declare global {
interface Window {
electron: ElectronAPI
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
send: (channel: string, ...args: unknown[]) => void
}

View File

@@ -1,4 +1,3 @@
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
@@ -10,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { toast } from 'sonner'
import { ErrorBoundary } from './components/error/ErrorBoundary'
import { useDownloadEvents } from './hooks/use-download-events'
import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About'
import { Home } from './pages/Home'
@@ -55,7 +55,7 @@ function AppContent() {
const loadSettings = useSetAtom(loadSettingsAtom)
const setUpdateReady = useSetAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const { t } = useTranslation()
const { i18n } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
const navigate = useNavigate()
@@ -63,6 +63,8 @@ function AppContent() {
const currentPage = pathToPage(location.pathname)
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
useDownloadEvents()
const handlePageChange = useCallback(
(page: Page) => {
const targetPath = pageToPath[page] ?? '/'
@@ -197,14 +199,27 @@ function AppContent() {
available: true,
version: info.version
})
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: i18n.t('about.notifications.updateDownloaded')
toast.info(downloadedMessage, {
action: {
label: i18n.t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const handleUpdateError = (rawMessage: unknown) => {
const message = typeof rawMessage === 'string' ? rawMessage : ''
resetDownloadState()
const errorMessage = message || t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: errorMessage }))
const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')
toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))
}
const handleDownloadProgress = (rawProgress: unknown) => {
@@ -214,39 +229,20 @@ function AppContent() {
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { version?: string }
const versionLabel = payload?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
// Only listen to update events that should be shown globally
// update:available shows a visual indicator in the sidebar
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
ipcEvents.on('update:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [setUpdateAvailable, setUpdateReady, t])
}, [i18n, setUpdateAvailable, setUpdateReady])
return (
<div className="flex flex-row h-screen">
@@ -262,28 +258,23 @@ function AppContent() {
{/* Custom Title Bar */}
<TitleBar platform={platform} />
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
style={{ maxWidth: '100%' }}
>
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
<Routes>
<Route
path="/"
element={
<Home
onOpenSupportedSites={handleOpenSupportedSites}
onOpenSettings={() => handlePageChange('settings')}
/>
}
/>
<Route path="/subscriptions" element={<Subscriptions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</ScrollArea>
<div className="flex-1 h-full overflow-y-auto overflow-x-hidden">
<Routes>
<Route
path="/"
element={
<Home
onOpenSupportedSites={handleOpenSupportedSites}
onOpenSettings={() => handlePageChange('settings')}
/>
}
/>
<Route path="/subscriptions" element={<Subscriptions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</main>
<Toaster richColors={true} />

View File

@@ -8,11 +8,16 @@
@apply border-border;
}
body {
@apply text-foreground;
@apply text-foreground select-none;
}
input,
textarea,
[contenteditable='true'],
[contenteditable=''] {
@apply select-text;
}
input::placeholder,
textarea::placeholder {
opacity: 0.4;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
import { Checkbox } from '@renderer/components/ui/checkbox'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { cn } from '@renderer/lib/utils'
import type { PlaylistInfo } from '@shared/types'
import { AlertCircle, List, Loader2 } from 'lucide-react'
import type { Dispatch, SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
interface PlaylistDownloadProps {
playlistPreviewLoading: boolean
playlistPreviewError: string | null
playlistInfo: PlaylistInfo | null
playlistBusy: boolean
selectedPlaylistEntries: PlaylistInfo['entries']
selectedEntryIds: Set<string>
downloadType: 'video' | 'audio'
downloadTypeId: string
startIndex: string
endIndex: string
advancedOptionsOpen: boolean
setSelectedEntryIds: Dispatch<SetStateAction<Set<string>>>
setStartIndex: Dispatch<SetStateAction<string>>
setEndIndex: Dispatch<SetStateAction<string>>
setDownloadType: Dispatch<SetStateAction<'video' | 'audio'>>
}
export function PlaylistDownload({
playlistPreviewLoading,
playlistPreviewError,
playlistInfo,
playlistBusy,
selectedPlaylistEntries,
selectedEntryIds,
downloadType,
downloadTypeId,
startIndex,
endIndex,
advancedOptionsOpen,
setSelectedEntryIds,
setStartIndex,
setEndIndex,
setDownloadType
}: PlaylistDownloadProps) {
const { t } = useTranslation()
return (
<>
{playlistPreviewLoading && !playlistPreviewError && (
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">{t('playlist.fetchingInfo')}</p>
</div>
)}
{playlistPreviewError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 mb-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-destructive">{t('playlist.previewFailed')}</p>
<p className="text-xs text-muted-foreground/80">{playlistPreviewError}</p>
</div>
</div>
</div>
)}
{playlistInfo && !playlistPreviewLoading && (
<div className="flex-1 flex flex-col min-h-0 gap-3">
<div className="space-y-0.5 shrink-0">
<h3 className="font-bold text-sm leading-tight line-clamp-1">{playlistInfo.title}</h3>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<List className="h-3 w-3" />
<span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>
{selectedPlaylistEntries.length !== playlistInfo.entryCount && (
<>
<span></span>
<span className="text-primary font-medium">
{t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })}
</span>
</>
)}
</div>
</div>
<ScrollArea className="flex-1 w-full rounded-md border max-h-[45vh]">
<div className="p-1">
{playlistInfo.entries.map((entry) => {
const isSelected = selectedEntryIds.has(entry.id)
const isInRange =
selectedEntryIds.size === 0 &&
selectedPlaylistEntries.some((playlistEntry) => playlistEntry.id === entry.id)
const handleToggle = () => {
setSelectedEntryIds((prev) => {
const next = new Set(prev)
if (next.has(entry.id)) {
next.delete(entry.id)
} else {
next.add(entry.id)
}
return next
})
if (selectedEntryIds.size === 0) {
setStartIndex('1')
setEndIndex('')
}
}
return (
<button
key={entry.id}
type="button"
className={cn(
'flex items-center gap-3 px-2.5 py-1.5 rounded transition-colors cursor-pointer w-full text-left',
isSelected || isInRange ? 'bg-primary/10' : 'hover:bg-muted/50'
)}
onClick={handleToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleToggle()
}
}}
aria-label={t('playlist.selectEntry', { index: entry.index })}
>
<Checkbox
checked={isSelected || isInRange}
onCheckedChange={(checked) => {
setSelectedEntryIds((prev) => {
const next = new Set(prev)
if (checked) {
next.add(entry.id)
} else {
next.delete(entry.id)
}
return next
})
if (selectedEntryIds.size === 0) {
setStartIndex('1')
setEndIndex('')
}
}}
onClick={(event) => event.stopPropagation()}
className="shrink-0"
/>
<div className="shrink-0 w-8 text-xs font-medium text-muted-foreground/70 tabular-nums">
#{entry.index}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium line-clamp-1 leading-tight">
{entry.title || t('download.fetchingVideoInfo')}
</p>
</div>
</button>
)
})}
</div>
</ScrollArea>
<div
data-state={advancedOptionsOpen ? 'open' : 'closed'}
className={cn(
'grid overflow-hidden transition-all duration-300 ease-out shrink-0',
advancedOptionsOpen ? 'grid-rows-[1fr] py-3 opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!advancedOptionsOpen}
>
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
<div className="w-full pt-3 border-t">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label
htmlFor={downloadTypeId}
className="text-xs font-medium text-muted-foreground"
>
{t('playlist.downloadType')}
</Label>
<Select
value={downloadType}
onValueChange={(value) => setDownloadType(value as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId} className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video" className="text-xs">
{t('download.video')}
</SelectItem>
<SelectItem value="audio" className="text-xs">
{t('download.audio')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{t('playlist.range')}
</Label>
<div className="flex items-center gap-2">
<Input
placeholder="1"
value={startIndex}
onChange={(event) => {
setStartIndex(event.target.value)
if (selectedEntryIds.size > 0) {
setSelectedEntryIds(new Set())
}
}}
className="text-center h-8 text-xs"
disabled={playlistBusy}
/>
<span className="text-muted-foreground text-xs">-</span>
<Input
placeholder={playlistInfo?.entryCount.toString() || 'End'}
value={endIndex}
onChange={(event) => {
setEndIndex(event.target.value)
if (selectedEntryIds.size > 0) {
setSelectedEntryIds(new Set())
}
}}
className="text-center h-8 text-xs"
disabled={playlistBusy}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -78,11 +78,11 @@ export function PlaylistDownloadGroup({
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
return (
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
<div className="rounded-md bg-muted/30 mx-6">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
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)}
aria-expanded={isExpanded}
aria-label={toggleLabel}
@@ -118,7 +118,7 @@ export function PlaylistDownloadGroup({
</div>
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-1 pr-3">
{canDeletePlaylist && (
<Button
type="button"
@@ -153,17 +153,15 @@ export function PlaylistDownloadGroup({
}}
>
<div className="min-h-0">
<div className="space-y-3 pt-1">
{records.map((record) => (
<div key={`${groupId}:${record.entryType}:${record.id}`}>
<DownloadItem
download={record}
isSelected={selectedIds?.has(record.id) ?? false}
onToggleSelect={onToggleSelect}
/>
</div>
))}
</div>
{records.map((record) => (
<div key={`${groupId}:${record.entryType}:${record.id}`}>
<DownloadItem
download={record}
isSelected={selectedIds?.has(record.id) ?? false}
onToggleSelect={onToggleSelect}
/>
</div>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,800 @@
import { Button } from '@renderer/components/ui/button'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Label } from '@renderer/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Separator } from '@renderer/components/ui/separator'
import { cn } from '@renderer/lib/utils'
import type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types'
import { useAtom } from 'jotai'
import { AlertCircle, ExternalLink, Loader2, Settings2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { settingsAtom } from '../../store/settings'
import { DOWNLOAD_FEEDBACK_ISSUE_TITLE, FeedbackLinkButtons } from '../feedback/FeedbackLinks'
export interface SingleVideoState {
title: string
activeTab: 'video' | 'audio'
selectedVideoFormat: string
selectedAudioFormat: string
customDownloadPath: string
selectedContainer?: string
selectedCodec?: string
selectedFps?: string
}
interface SingleVideoDownloadProps {
loading: boolean
error: string | null
videoInfo: VideoInfo | null
state: SingleVideoState
feedbackSourceUrl?: string | null
ytDlpCommand?: string
onStateChange: (state: Partial<SingleVideoState>) => void
}
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const formatDuration = (seconds?: number): string => {
if (!seconds) return '00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
const getCodecShortName = (codec?: string): string => {
if (!codec || codec === 'none') return 'Unknown'
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 = (
formats: VideoInfo['formats'],
activeTab: 'video' | 'audio'
): VideoInfo['formats'] => {
if (!formats) return []
return formats.filter((format) => {
if (activeTab === 'video') {
return format.vcodec && format.vcodec !== 'none'
}
return (
format.acodec &&
format.acodec !== 'none' &&
(format.video_ext === 'none' ||
!format.video_ext ||
!format.vcodec ||
format.vcodec === 'none')
)
})
}
interface FormatListProps {
formats: VideoFormat[]
type: 'video' | 'audio'
codec?: string
selectedFormat: string
onFormatChange: (formatId: string) => void
}
const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: FormatListProps) => {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
const getFileSize = useCallback((format: VideoFormat): number => {
return format.filesize ?? format.filesize_approx ?? 0
}, [])
const sortVideoFormatsByQuality = useCallback(
(a: VideoFormat, b: VideoFormat) => {
const aHeight = a.height ?? 0
const bHeight = b.height ?? 0
if (aHeight !== bHeight) {
return bHeight - aHeight
}
const aFps = a.fps ?? 0
const bFps = b.fps ?? 0
if (aFps !== bFps) {
return bFps - aFps
}
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return getFileSize(b) - getFileSize(a)
},
[getFileSize]
)
const sortAudioFormatsByQuality = useCallback(
(a: VideoFormat, b: VideoFormat) => {
const aQuality = a.tbr ?? a.quality ?? 0
const bQuality = b.tbr ?? b.quality ?? 0
if (aQuality !== bQuality) {
return bQuality - aQuality
}
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return getFileSize(b) - getFileSize(a)
},
[getFileSize]
)
const pickVideoFormatForPreset = useCallback(
(presetFormats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
if (presetFormats.length === 0) {
return null
}
const heightLimit = qualityPresetToVideoHeight[preset]
const sorted = [...presetFormats].sort(sortVideoFormatsByQuality)
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
if (!heightLimit) {
return sorted[0]
}
const matchingLimit = sorted.find((format) => {
if (!format.height) return false
return format.height <= heightLimit
})
return matchingLimit ?? sorted[0]
},
[sortVideoFormatsByQuality]
)
useEffect(() => {
const isVideoFormat = (format: VideoFormat) =>
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
const isAudioFormat = (format: VideoFormat) =>
format.acodec &&
format.acodec !== 'none' &&
(format.video_ext === 'none' ||
!format.video_ext ||
!format.vcodec ||
format.vcodec === 'none')
const videos = formats.filter(isVideoFormat)
const audios = formats.filter(isAudioFormat)
const groupedByHeight = new Map<number, VideoFormat[]>()
videos.forEach((format) => {
const height = format.height ?? 0
const existing = groupedByHeight.get(height) || []
existing.push(format)
groupedByHeight.set(height, existing)
})
const finalVideos = Array.from(groupedByHeight.values()).map((group) => {
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
})
let finalAudios = audios
if (codec === 'auto' && type === 'audio') {
const groupedByQuality = new Map<string, VideoFormat[]>()
audios.forEach((format) => {
const qualityKey = format.tbr
? `tbr_${format.tbr}`
: format.quality
? `quality_${format.quality}`
: 'unknown'
const existing = groupedByQuality.get(qualityKey) || []
existing.push(format)
groupedByQuality.set(qualityKey, existing)
})
finalAudios = Array.from(groupedByQuality.values()).map((group) => {
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
})
}
finalVideos.sort(sortVideoFormatsByQuality)
finalAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(finalVideos)
setAudioFormats(finalAudios)
if (type === 'video') {
const videosWithAudio = finalVideos.filter(
(format) => format.acodec && format.acodec !== 'none'
)
const autoVideos =
finalAudios.length > 0
? finalVideos
: videosWithAudio.length > 0
? videosWithAudio
: finalVideos
const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedFormat)
if (autoVideos.length > 0 && (!selectedFormat || !hasSelectedVideo)) {
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
if (preferred) {
onFormatChange(preferred.format_id)
}
}
} else {
const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedFormat)
if (finalAudios.length > 0 && (!selectedFormat || !hasSelectedAudio)) {
const best = finalAudios[0]
onFormatChange(best.format_id)
}
}
}, [
formats,
settings.oneClickQuality,
type,
selectedFormat,
onFormatChange,
pickVideoFormatForPreset,
codec,
getFileSize,
sortVideoFormatsByQuality,
sortAudioFormatsByQuality
])
const formatSize = (bytes?: number) => {
if (!bytes) return t('download.unknownSize')
const mb = bytes / 1000000
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) => {
if (format.height) {
return `${format.height}p${format.fps === 60 ? '60' : ''}`
}
if (format.format_note) {
return format.format_note
}
if (typeof format.quality === 'number') {
return format.quality.toString()
}
return t('download.unknownQuality')
}
const formatAudioQuality = (format: VideoFormat) => {
if (format.tbr) {
return `${Math.round(format.tbr)} kbps`
}
if (format.format_note) {
return format.format_note
}
if (typeof format.quality === 'number') {
return format.quality.toString()
}
return t('download.unknownQuality')
}
const formatVideoDetail = (format: VideoFormat) => {
const parts: string[] = []
parts.push(format.ext.toUpperCase())
if (format.vcodec) {
parts.push(format.vcodec.split('.')[0].toUpperCase())
}
if (format.acodec && format.acodec !== 'none') {
parts.push(format.acodec.split('.')[0].toUpperCase())
}
return parts.join(' • ')
}
const formatAudioDetail = (format: VideoFormat) => {
const parts: string[] = []
const ext = format.ext === 'webm' ? 'opus' : format.ext
parts.push(ext.toUpperCase())
if (format.acodec) {
parts.push(format.acodec.split('.')[0].toUpperCase())
}
return parts.join(' • ')
}
const list = type === 'video' ? videoFormats : audioFormats
if (list.length === 0) {
return null
}
return (
<RadioGroup value={selectedFormat} onValueChange={onFormatChange} className="w-full gap-1">
{list.map((format) => {
const qualityLabel =
type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format)
const detailLabel = type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format)
const thirdColumnLabel =
type === 'video'
? format.fps
? `${format.fps}fps`
: ''
: format.acodec
? format.acodec.split('.')[0].toUpperCase()
: ''
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
const metaLabel = formatMetaLabel(format)
const isSelected = selectedFormat === format.format_id
return (
<label
key={format.format_id}
htmlFor={`${type}-${format.format_id}`}
className={cn(
'relative flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors rounded-md',
isSelected ? 'bg-primary/10' : 'hover:bg-muted'
)}
>
<RadioGroupItem
value={format.format_id}
id={`${type}-${format.format_id}`}
className="shrink-0 hidden"
/>
<div className="flex-1 min-w-0 flex items-center gap-4">
<span
className={cn('text-sm font-medium w-16 shrink-0', isSelected && 'text-primary')}
>
{qualityLabel}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
{thirdColumnLabel && thirdColumnLabel !== '-' && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
{thirdColumnLabel}
</span>
)}
</div>
{metaLabel && (
<div className="mt-0.5 text-[10px] text-muted-foreground/70 leading-snug break-words">
{metaLabel}
</div>
)}
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0 w-20 text-right">
{sizeLabel}
</span>
</div>
</label>
)
})}
</RadioGroup>
)
}
export function SingleVideoDownload({
loading,
error,
videoInfo,
state,
feedbackSourceUrl,
ytDlpCommand,
onStateChange
}: SingleVideoDownloadProps) {
const { t } = useTranslation()
const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)
const [showAdvanced, setShowAdvanced] = useState(false)
const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state
const displayTitle = title || videoInfo?.title || t('download.fetchingVideoInfo')
const relevantFormats = useMemo(() => {
if (!videoInfo?.formats) return []
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])
const containers = useMemo(() => {
if (relevantFormats.length === 0) return []
const exts = new Set(relevantFormats.map((format) => format.ext))
return Array.from(exts).sort()
}, [relevantFormats])
useEffect(() => {
if (containers.length === 0) return undefined
if (selectedContainer && !containers.includes(selectedContainer)) {
let defaultContainer: string
if (activeTab === 'video') {
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
} else {
defaultContainer = containers.includes('m4a')
? 'm4a'
: containers.includes('mp3')
? 'mp3'
: containers[0]
}
const timer = setTimeout(() => {
onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' })
}, 0)
return () => clearTimeout(timer)
}
if (!selectedContainer) {
let defaultContainer: string
if (activeTab === 'video') {
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
} else {
defaultContainer = containers.includes('m4a')
? 'm4a'
: containers.includes('mp3')
? 'mp3'
: containers[0]
}
const timer = setTimeout(() => {
onStateChange({ selectedContainer: defaultContainer })
}, 0)
return () => clearTimeout(timer)
}
return undefined
}, [containers, selectedContainer, activeTab, onStateChange])
const formatsByContainer = useMemo(() => {
if (relevantFormats.length === 0) return []
if (!selectedContainer) {
return relevantFormats
}
return relevantFormats.filter((format) => format.ext === selectedContainer)
}, [relevantFormats, selectedContainer])
const codecs = useMemo(() => {
if (formatsByContainer.length === 0) return []
const SetVals = new Set<string>()
formatsByContainer.forEach((format) => {
if (activeTab === 'video') {
const c = format.vcodec
if (c && c !== 'none') {
SetVals.add(getCodecShortName(c))
}
} else {
const c = format.acodec
if (c && c !== 'none') {
SetVals.add(getCodecShortName(c))
}
}
})
return Array.from(SetVals).sort()
}, [formatsByContainer, activeTab])
useEffect(() => {
if (codecs.length === 0) return undefined
if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) {
const timer = setTimeout(() => {
onStateChange({ selectedCodec: 'auto' })
}, 0)
return () => clearTimeout(timer)
}
return undefined
}, [codecs, selectedCodec, onStateChange])
const formatsByCodec = useMemo(() => {
if (!selectedCodec || selectedCodec === 'auto') return formatsByContainer
return formatsByContainer.filter((format) => {
if (activeTab === 'video') {
const c = format.vcodec
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
}
const c = format.acodec
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
})
}, [formatsByContainer, selectedCodec, activeTab])
const framerates = useMemo(() => {
if (activeTab !== 'video') return []
const SetVals = new Set<number>()
formatsByCodec.forEach((format) => {
if (format.fps) SetVals.add(format.fps)
})
return Array.from(SetVals).sort((a, b) => b - a)
}, [formatsByCodec, activeTab])
const filteredFormats = useMemo(() => {
let res = formatsByCodec
if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') {
res = res.filter((format) => format.fps === Number(selectedFps))
}
return res
}, [formatsByCodec, selectedFps, activeTab])
return (
<div className="flex flex-col flex-1 min-h-0">
{loading && !error && (
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">{t('download.fetchingVideoInfo')}</p>
</div>
)}
{error && (
<div className="shrink-0 mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 space-y-1 min-w-0">
<p className="text-sm font-medium text-destructive">{t('errors.fetchInfoFailed')}</p>
<p className="text-xs text-muted-foreground/80 break-words">{error}</p>
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-medium text-muted-foreground/70">
{t('download.feedback.title')}
</span>
<div className="flex flex-wrap gap-1.5">
<FeedbackLinkButtons
error={error}
sourceUrl={feedbackSourceUrl}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-5 gap-1 px-1.5 text-[10px]"
iconClassName="h-2.5 w-2.5"
/>
</div>
</div>
</div>
)}
{!loading && videoInfo && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex gap-4 py-4 shrink-0">
<div className="shrink-0 w-32 relative rounded-md overflow-hidden bg-muted">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={displayTitle}
className="w-full h-full object-cover aspect-video"
/>
<div className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] px-1 rounded">
{formatDuration(videoInfo.duration)}
</div>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between py-0.5">
<div className="space-y-0.5">
<h3 className="font-bold text-[13px] leading-tight line-clamp-2">{displayTitle}</h3>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{videoInfo.uploader && (
<span className="truncate max-w-[140px] uppercase tracking-wider font-semibold opacity-70">
{videoInfo.uploader}
</span>
)}
{videoInfo.webpage_url && (
<a
href={videoInfo.webpage_url}
target="_blank"
rel="noreferrer"
className="hover:text-primary transition-colors"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex p-0.5 bg-muted rounded-md gap-0.5">
<Button
variant={activeTab === 'video' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onStateChange({ activeTab: 'video' })}
className={cn(
'h-5 px-2 text-[11px] rounded-sm',
activeTab === 'video'
? 'bg-background text-foreground'
: 'text-muted-foreground/60'
)}
>
{t('download.video')}
</Button>
<Button
variant={activeTab === 'audio' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onStateChange({ activeTab: 'audio' })}
className={cn(
'h-5 px-2 text-[11px] rounded-sm',
activeTab === 'audio'
? 'bg-background text-foreground'
: 'text-muted-foreground/60'
)}
>
{t('download.audio')}
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className={cn(
'h-6 w-6 p-0 rounded-full hover:bg-muted font-normal text-muted-foreground transition-colors',
showAdvanced && 'bg-muted text-foreground'
)}
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Separator />
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div
className={cn(
'grid transition-all duration-300 ease-in-out',
showAdvanced ? 'grid-rows-[1fr] py-3 border-b' : 'grid-rows-[0fr]'
)}
>
<div className="overflow-hidden min-h-0">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5 flex-1 min-w-[120px]">
<Label className="text-xs text-muted-foreground font-medium px-0.5">
{t('download.container') || 'Format'}
</Label>
<Select
value={selectedContainer || ''}
onValueChange={(value) => onStateChange({ selectedContainer: value })}
disabled={containers.length <= 1}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Container" />
</SelectTrigger>
<SelectContent>
{containers.map((ext) => (
<SelectItem key={ext} value={ext} className="text-xs">
{ext.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 flex-1 min-w-[120px]">
<Label className="text-xs text-muted-foreground font-medium px-0.5">
Codec
</Label>
<Select
value={selectedCodec || 'auto'}
onValueChange={(value) => onStateChange({ selectedCodec: value })}
disabled={codecs.length <= 1}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Auto" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">
Auto
</SelectItem>
{codecs.map((codecName) => (
<SelectItem key={codecName} value={codecName} className="text-xs">
{codecName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeTab === 'video' && (
<div className="space-y-1.5 flex-1 min-w-[120px]">
<Label className="text-xs text-muted-foreground font-medium px-0.5">
Frame Rate
</Label>
<Select
value={selectedFps || 'highest'}
onValueChange={(value) => onStateChange({ selectedFps: value })}
disabled={framerates.length === 0}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Highest" />
</SelectTrigger>
<SelectContent>
<SelectItem value="highest" className="text-xs">
Highest
</SelectItem>
{framerates.map((fps) => (
<SelectItem key={fps} value={String(fps)} className="text-xs">
{fps} fps
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</div>
<ScrollArea className="flex-1 overflow-y-auto my-3 max-h-72">
<FormatList
formats={filteredFormats}
type={activeTab}
codec={selectedCodec}
selectedFormat={
activeTab === 'video' ? state.selectedVideoFormat : state.selectedAudioFormat
}
onFormatChange={(formatId) =>
onStateChange(
activeTab === 'video'
? { selectedVideoFormat: formatId }
: { selectedAudioFormat: formatId }
)
}
/>
</ScrollArea>
</div>
</div>
)}
</div>
)
}

View File

@@ -25,6 +25,7 @@ import {
removeHistoryRecordsByPlaylistAtom
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
import { ScrollArea } from '../ui/scroll-area'
import { DownloadDialog } from './DownloadDialog'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
@@ -112,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
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 {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
@@ -162,6 +174,12 @@ export function UnifiedDownloadHistory({
})
}, [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 }> = [
{ key: 'all', label: t('download.all'), count: downloadStats.total },
{ key: 'active', label: t('download.active'), count: downloadStats.active },
@@ -169,16 +187,34 @@ export function UnifiedDownloadHistory({
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const selectableIds = useMemo(
() =>
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
[filteredRecords]
)
const selectableIds = useMemo(() => {
if (visibleHistoryIds.length === 0) {
return []
}
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 visibleSelectableCount = visibleHistoryIds.length
const selectionSummary =
selectableCount === 0
? 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(() => {
if (selectedIds.size === 0) {
@@ -215,6 +251,13 @@ export function UnifiedDownloadHistory({
setSelectedIds(new Set())
}
const handleSelectAll = () => {
if (selectableIds.length === 0) {
return
}
setSelectedIds(new Set(selectableIds))
}
const handleRequestDeleteSelected = () => {
if (selectedIds.size === 0) {
return
@@ -397,9 +440,44 @@ export function UnifiedDownloadHistory({
return { order, groups }
}, [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 (
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
<div className={cn('flex flex-col h-full')}>
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
{filters.map((filter) => {
@@ -430,6 +508,15 @@ export function UnifiedDownloadHistory({
})}
</div>
<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
onOpenSupportedSites={onOpenSupportedSites}
onOpenSettings={onOpenSettings}
@@ -437,47 +524,49 @@ export function UnifiedDownloadHistory({
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-4 w-full">
{groupedView.order.map((item) => {
if (item.type === 'single') {
<ScrollArea className="overflow-y-auto flex-1">
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="w-full pb-4">
{groupedView.order.map((item) => {
if (item.type === 'single') {
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
isSelected={selectedIds.has(item.record.id)}
onToggleSelect={handleToggleSelect}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
isSelected={selectedIds.has(item.record.id)}
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onDeletePlaylist={handleRequestDeletePlaylist}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onDeletePlaylist={handleRequestDeletePlaylist}
/>
)
})}
</div>
)}
</CardContent>
})}
</div>
)}
</CardContent>
</ScrollArea>
{selectedCount > 0 && (
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">

Some files were not shown because too many files have changed in this diff Show More