Compare commits
63 Commits
v1.1.1
...
nexmoe/bag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9a0e39f7 | ||
|
|
cab86fe71c | ||
|
|
129899ed61 | ||
|
|
1f821b67dd | ||
|
|
6f1c6ce59e | ||
|
|
e5d36da904 | ||
|
|
9c3fd2c26f | ||
|
|
894eb9774b | ||
|
|
73af211bef | ||
|
|
4b561cef38 | ||
|
|
761adf2476 | ||
|
|
182a1b9c1e | ||
|
|
59aee07913 | ||
|
|
3d39c9751f | ||
|
|
2bf7af9b25 | ||
|
|
fe55ac1a79 | ||
|
|
c1d1a1b912 | ||
|
|
9d377dd464 | ||
|
|
be00f5b2a4 | ||
|
|
2d69ce81a4 | ||
|
|
4a9ee1406b | ||
|
|
aed6842319 | ||
|
|
e7957d09ec | ||
|
|
5057d18e09 | ||
|
|
3c1f52b06b | ||
|
|
f061b6b61e | ||
|
|
43cabf65e9 | ||
|
|
da3643bc79 | ||
|
|
976cb82bf9 | ||
|
|
14f80309af | ||
|
|
8acd761189 | ||
|
|
20c6ae6543 | ||
|
|
66f2211d68 | ||
|
|
ac183b4e3c | ||
|
|
877f088136 | ||
|
|
5ee08a3e62 | ||
|
|
137a07cf4e | ||
|
|
21a5adbdf2 | ||
|
|
81f52f86e5 | ||
|
|
c8147cafe3 | ||
|
|
7074f698e5 | ||
|
|
b254bb6dba | ||
|
|
1caca0ea9a | ||
|
|
12ec142a77 | ||
|
|
0136da410d | ||
|
|
4971a5fe8a | ||
|
|
d2806164a2 | ||
|
|
e332ec4ddc | ||
|
|
3b74712f57 | ||
|
|
475e4127c2 | ||
|
|
3041307aa2 | ||
|
|
e2ab8dce60 | ||
|
|
c1806e5321 | ||
|
|
92494966c6 | ||
|
|
f04680b8c2 | ||
|
|
a5b94411fe | ||
|
|
71ae4a4425 | ||
|
|
e8413aefae | ||
|
|
b229225b6e | ||
|
|
5497ed6245 | ||
|
|
155bf652f2 | ||
|
|
86bd77d995 | ||
|
|
b38b356c22 |
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Bug Report
|
||||
description: Report a problem or regression
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: input
|
||||
id: app_version
|
||||
attributes:
|
||||
label: App version
|
||||
description: The VidBee version (e.g., 1.2.3)
|
||||
placeholder: 1.2.3
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os_version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
|
||||
placeholder: macOS 14.2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Observed behavior
|
||||
placeholder: What actually happened
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots
|
||||
description: Paste relevant logs or add screenshots
|
||||
placeholder: Attach files or paste logs here
|
||||
validations:
|
||||
required: false
|
||||
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What problem are you trying to solve?
|
||||
placeholder: I want to...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the feature or change you want
|
||||
placeholder: It would be great if...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other solutions or workarounds you considered
|
||||
placeholder: I tried...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots
|
||||
placeholder: Links, screenshots, or related issues
|
||||
validations:
|
||||
required: false
|
||||
171
.github/workflows/build.yml
vendored
@@ -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,24 +77,45 @@ 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 -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||
|
||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||
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)
|
||||
@@ -103,16 +145,23 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
|
||||
exit 1
|
||||
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
|
||||
run: |
|
||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||
fi
|
||||
@@ -120,14 +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
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
@@ -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
@@ -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
|
||||
7
.github/workflows/release.yml
vendored
@@ -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"
|
||||
|
||||
2
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.wxt
|
||||
.output
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
68
README.md
@@ -26,48 +26,7 @@ VidBee is a modern, open-source video downloader that lets you download videos a
|
||||
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
Feel free to try it using the following methods:
|
||||
|
||||
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
|
||||
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
|
||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||
|
||||
### 🌐 Browser Script (Quick Download)
|
||||
|
||||
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
|
||||
|
||||

|
||||
|
||||
**Installation:**
|
||||
|
||||
1. Install a userscript manager extension:
|
||||
- [Tampermonkey](https://www.tampermonkey.net/) (Recommended)
|
||||
- [Violentmonkey](https://violentmonkey.github.io/)
|
||||
- [Greasemonkey](https://www.greasespot.net/)
|
||||
|
||||
2. Install the VidBee Quick Download script:
|
||||
- [Install from Greasy Fork](https://greasyfork.org/zh-CN/scripts/559595-vidbee-quick-download)
|
||||
|
||||
**Usage:**
|
||||
|
||||
After installation, when you visit supported video websites (YouTube, Bilibili, TikTok, Instagram, Twitter, etc.), a download button will appear on the page. Click the button to quickly send the video URL to VidBee desktop app for downloading.
|
||||
|
||||
**Supported Sites:**
|
||||
|
||||
The script works on popular video platforms including:
|
||||
|
||||
- YouTube, Bilibili, TikTok, Vimeo, Dailymotion
|
||||
- Twitch, Twitter/X, Instagram, Facebook
|
||||
- Reddit, SoundCloud, Niconico, Kick
|
||||
- Bandcamp, Mixcloud, and more
|
||||
[📥 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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "cp $CONDUCTOR_ROOT_PATH/resources/ resources/ && pnpm install",
|
||||
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
|
||||
"run": "pnpm run dev"
|
||||
}
|
||||
}
|
||||
|
||||
26
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# deps
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.source
|
||||
|
||||
# test & build
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# others
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
45
docs/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# docs
|
||||
|
||||
This is a Next.js application generated with
|
||||
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||
|
||||
Run development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||
features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
41
docs/biome.config.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!node_modules",
|
||||
"!.next",
|
||||
"!dist",
|
||||
"!build",
|
||||
"!.source"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
docs/content/cookies.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Cookies
|
||||
description: Signed-in downloads, restricted content, and cookie setup
|
||||
---
|
||||
|
||||
Cookies reuse your browser's signed-in session so VidBee can download content that requires login or verification, such as subscriber-only items, age-gated pages, or private links.
|
||||
|
||||
## When you need cookies
|
||||
|
||||
- Content that requires an account to view or download.
|
||||
- Age-gated or region-locked pages.
|
||||
- Private links or lists visible only to signed-in users.
|
||||
|
||||
## Two ways to use cookies in VidBee
|
||||
|
||||
### Option 1: Read cookies from your browser
|
||||
|
||||
In **Settings**, select your browser. VidBee will try to detect the browser profile path automatically. You can also enter the profile path manually.
|
||||
|
||||
**Supported browsers (platform dependent):**
|
||||
|
||||
- **Windows: Firefox only. Other browsers cannot be used for cookie reading.**
|
||||
- macOS: Safari, Chrome, Edge, Brave, Firefox, and more.
|
||||
- Linux: Chrome, Chromium, Brave, Firefox, and more.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Open VidBee → Settings → Cookies.
|
||||
2. Select a browser and confirm the profile path.
|
||||
3. Go back and start the download again.
|
||||
|
||||
If detection fails or the path is invalid, manually choose the actual browser profile directory.
|
||||
On Windows, if you are not using Firefox, switch to the cookies file method.
|
||||
|
||||
### Option 2: Import a cookies file
|
||||
|
||||
You can import a **Netscape-formatted** cookies file. This is useful when reading the browser profile is not possible.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Export a Netscape cookies file using a browser extension.
|
||||
2. Open VidBee → Settings → Cookies file, and select the exported file.
|
||||
3. Click Clear to disable it.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- Prefer browser-based cookies when possible, because it's easier to maintain.
|
||||
- If browser reading fails, switch to a cookies file.
|
||||
- Cookies files expire. Re-export when your account state changes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Profile path is invalid**: Ensure the folder exists and points to the real browser profile directory.
|
||||
- **Still asks for login**: Confirm your browser is signed in and re-save the setting.
|
||||
- **Browser read failed**: Close the browser and retry, or switch to a cookies file.
|
||||
|
||||
## Privacy and security
|
||||
|
||||
Cookies are equivalent to your login session. Keep them private and never share or upload them. If you suspect leakage, sign out and change your password.
|
||||
36
docs/content/faq.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: FAQ
|
||||
description: Common questions and troubleshooting for VidBee
|
||||
---
|
||||
|
||||
## What if a download fails or shows an error?
|
||||
|
||||
- Confirm the link is valid and opens in your browser.
|
||||
- Update VidBee to the latest version and retry.
|
||||
- If the content requires login or age verification, configure cookies.
|
||||
|
||||
## Why does the same link sometimes work and sometimes fail?
|
||||
|
||||
Some sites frequently change page structure or limit request rates. Suggested steps:
|
||||
|
||||
- Make sure VidBee is up to date.
|
||||
- Reduce the number of concurrent downloads.
|
||||
- Use cookies to reuse your signed-in session when needed.
|
||||
|
||||
## Which sites are supported?
|
||||
|
||||
VidBee uses the yt-dlp extractor system and supports many sites. Try the download first; if it fails, submit feedback with the link and error details.
|
||||
|
||||
## Why is the download speed slow?
|
||||
|
||||
- Check your network and proxy settings.
|
||||
- Avoid starting too many tasks at once.
|
||||
- Some sites limit bandwidth on their side.
|
||||
|
||||
## macOS says “file is damaged”
|
||||
|
||||
Remove the quarantine attribute and retry:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
19
docs/content/index.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: VidBee Docs
|
||||
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
|
||||
|
||||
- [Cookies](./cookies.mdx): Configure signed-in sessions and restricted content.
|
||||
- [FAQ](./faq.mdx): Common questions and troubleshooting.
|
||||
|
||||
## Quick links
|
||||
|
||||
- [VidBee website](https://vidbee.org/)
|
||||
- [Supported sites](https://vidbee.org/supported-sites/)
|
||||
- [Features](https://vidbee.org/features/)
|
||||
4
docs/content/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "VidBee Docs",
|
||||
"pages": ["index", "cookies", "faq"]
|
||||
}
|
||||
59
docs/content/zh/cookies.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Cookie 使用说明
|
||||
description: 登录下载、受限内容与 Cookie 配置
|
||||
---
|
||||
|
||||
Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或验证的内容,例如订阅内容、年龄限制或私密链接。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 需要账号登录才能观看或下载的内容。
|
||||
- 年龄限制或地区限制页面。
|
||||
- 仅对登录用户可见的私密链接或列表。
|
||||
|
||||
## VidBee 支持的两种方式
|
||||
|
||||
### 方式一:读取浏览器 Cookie
|
||||
|
||||
在 **设置** 中选择你的浏览器,VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。
|
||||
|
||||
**支持的浏览器(按平台差异显示):**
|
||||
|
||||
- **Windows:仅支持 Firefox。其他浏览器无法读取 Cookie。**
|
||||
- macOS:Safari、Chrome、Edge、Brave、Firefox 等。
|
||||
- Linux:Chrome、Chromium、Brave、Firefox 等。
|
||||
|
||||
**使用步骤:**
|
||||
|
||||
1. 打开 VidBee → 设置 → Cookie。
|
||||
2. 选择浏览器并确认配置文件路径。
|
||||
3. 返回下载页面,重新开始下载任务。
|
||||
|
||||
如果识别失败或路径无效,请手动选择实际的浏览器配置文件目录。
|
||||
在 Windows 上,如果不是 Firefox,请改用 cookies 文件方式。
|
||||
|
||||
### 方式二:导入 Cookies 文件
|
||||
|
||||
你也可以导入 **Netscape 格式** 的 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用。
|
||||
|
||||
**使用步骤:**
|
||||
|
||||
1. 使用浏览器扩展导出 Netscape cookies 文件。
|
||||
2. 打开 VidBee → 设置 → Cookies 文件,选择导出的文件。
|
||||
3. 如需停用,可点击“清除”。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 优先使用浏览器读取方式,维护成本更低。
|
||||
- 如果浏览器读取失败,再切换到 cookies 文件方式。
|
||||
- cookies 文件会过期,账号状态变化时需要重新导出。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **提示配置文件路径无效**:请确认路径存在,并指向实际的浏览器配置文件目录。
|
||||
- **下载仍提示需要登录**:确认当前浏览器已登录对应账号,并重新保存设置。
|
||||
- **浏览器读取失败**:关闭浏览器后重试,或改用 cookies 文件方式。
|
||||
|
||||
## 隐私与安全
|
||||
|
||||
Cookie 等同于登录态,请妥善保管,不要共享或上传。若怀疑泄露,请及时在网站上退出登录并更新密码。
|
||||
36
docs/content/zh/faq.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 常见问题 (FAQ)
|
||||
description: VidBee 使用过程中的常见问题与建议
|
||||
---
|
||||
|
||||
## 下载失败或报错怎么办?
|
||||
|
||||
- 先确认链接是否有效,并尝试在浏览器中打开。
|
||||
- 升级 VidBee 到最新版本后重试。
|
||||
- 如果是登录或年龄限制内容,请配置 Cookie。
|
||||
|
||||
## 为什么同一个链接有时可以、有时不行?
|
||||
|
||||
部分站点会频繁更新页面结构或限制请求频率。建议:
|
||||
|
||||
- 确保 VidBee 为最新版本。
|
||||
- 适当降低同时下载任务数量。
|
||||
- 必要时使用 Cookie 复用登录态。
|
||||
|
||||
## 支持哪些网站?
|
||||
|
||||
VidBee 基于 yt-dlp 解析器体系,覆盖大量站点。可以先尝试下载;若失败,请提交反馈并附上链接与错误提示。
|
||||
|
||||
## 下载速度慢怎么办?
|
||||
|
||||
- 检查本地网络与代理设置。
|
||||
- 避免同时发起过多任务。
|
||||
- 某些站点本身带宽较低,速度受限。
|
||||
|
||||
## macOS 提示“文件已损坏”
|
||||
|
||||
请执行以下命令移除隔离标记后重试:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
19
docs/content/zh/index.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: VidBee 文档
|
||||
description: VidBee 桌面下载器使用说明与常见问题
|
||||
---
|
||||
|
||||
VidBee 是一款基于 Electron 的桌面下载器,内置 yt-dlp 引擎,提供清爽的界面与队列管理能力,用于下载视频与音频内容。
|
||||
|
||||
这份文档聚焦 VidBee 的实际使用场景与设置说明,尤其是登录下载与 Cookie 相关配置。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
- [Cookie 使用](./cookies.mdx):登录态与限制内容的下载配置。
|
||||
- [常见问题](./faq.mdx):常见问题与排查思路。
|
||||
|
||||
## 快捷链接
|
||||
|
||||
- [VidBee 官网](https://vidbee.org/)
|
||||
- [支持站点](https://vidbee.org/supported-sites/)
|
||||
- [功能介绍](https://vidbee.org/features/)
|
||||
4
docs/content/zh/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "VidBee 文档",
|
||||
"pages": ["index", "cookies", "faq"]
|
||||
}
|
||||
15
docs/next.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
// Use trailing slashes to avoid conflicts with route handlers that have file extensions
|
||||
trailingSlash: true,
|
||||
// Note: rewrites are not supported with static export
|
||||
// The /llms.mdx route will be pre-rendered as static files
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
36
docs/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build && node scripts/post-export.js",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"lint": "biome check --config-path biome.config.json",
|
||||
"format": "biome format --write --config-path biome.config.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "16.4.7",
|
||||
"fumadocs-mdx": "14.2.5",
|
||||
"fumadocs-ui": "16.4.7",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.0.5",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"@biomejs/biome": "^2.3.11"
|
||||
}
|
||||
}
|
||||
3989
docs/pnpm-lock.yaml
generated
Normal file
5
docs/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
46
docs/scripts/post-export.js
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Post-export script to copy English content to root directory
|
||||
* This allows the default language (en) to be accessible without language prefix
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const outDir = join(__dirname, '../out');
|
||||
const enDir = join(outDir, 'en');
|
||||
|
||||
console.log('Copying English content to root directory...');
|
||||
|
||||
if (!existsSync(enDir)) {
|
||||
console.error('Error: /en directory not found in output');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get all items in /en directory
|
||||
const fs = await import('node:fs/promises');
|
||||
const items = await fs.readdir(enDir);
|
||||
|
||||
// Copy each item to root, excluding already existing root items
|
||||
for (const item of items) {
|
||||
const source = join(enDir, item);
|
||||
const dest = join(outDir, item);
|
||||
|
||||
// Skip if item already exists at root (like _next, api, etc.)
|
||||
if (existsSync(dest)) {
|
||||
console.log(`Skipping ${item} (already exists at root)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
cpSync(source, dest, { recursive: true });
|
||||
console.log(`Copied ${item}`);
|
||||
} catch (error) {
|
||||
console.error(`Error copying ${item}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('English content copied to root directory successfully!');
|
||||
22
docs/source.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config';
|
||||
|
||||
// You can customise Zod schemas for frontmatter and `meta.json` here
|
||||
// see https://fumadocs.dev/docs/mdx/collections
|
||||
export const docs = defineDocs({
|
||||
dir: 'content',
|
||||
docs: {
|
||||
schema: frontmatterSchema,
|
||||
postprocess: {
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
// MDX options
|
||||
},
|
||||
});
|
||||
67
docs/src/app/[lang]/(docs)/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
images: getPageImage(page).url,
|
||||
},
|
||||
};
|
||||
}
|
||||
20
docs/src/app/[lang]/(docs)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ lang: string; slug?: string[] }>;
|
||||
}) {
|
||||
const resolvedParams = await params;
|
||||
const locale = resolvedParams.lang;
|
||||
return (
|
||||
<DocsLayout tree={source.getPageTree(locale)} {...baseOptions(locale)}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
39
docs/src/app/[lang]/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import { i18n, isLocale } from '@/lib/i18n';
|
||||
|
||||
const { provider } = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
zh: {
|
||||
displayName: '中文',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
const locale = isLocale(lang) ? lang : i18n.defaultLanguage;
|
||||
|
||||
return (
|
||||
<RootProvider
|
||||
i18n={provider(locale)}
|
||||
search={{
|
||||
options: {
|
||||
type: 'static',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
14
docs/src/app/api/search/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
// statically cached for static export
|
||||
export const revalidate = false;
|
||||
|
||||
// Configure language support for both English and Chinese
|
||||
export const { staticGET: GET } = createFromSource(source, {
|
||||
localeMap: {
|
||||
en: { language: 'english' },
|
||||
// Chinese is not natively supported by Orama, use English tokenizer for zh
|
||||
zh: { language: 'english' },
|
||||
},
|
||||
});
|
||||
3
docs/src/app/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
26
docs/src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import './global.css';
|
||||
import { Inter } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import { i18n, isLocale } from '@/lib/i18n';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
250
docs/src/components/ai/page-actions.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function LLMCopyButton({
|
||||
/**
|
||||
* A URL to fetch the raw Markdown/MDX content of page
|
||||
*/
|
||||
markdownUrl,
|
||||
}: {
|
||||
markdownUrl: string;
|
||||
}) {
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [checked, onClick] = useCopyButton(async () => {
|
||||
const cached = cache.get(markdownUrl);
|
||||
if (cached) return navigator.clipboard.writeText(cached);
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': fetch(markdownUrl).then(async (res) => {
|
||||
const content = await res.text();
|
||||
cache.set(markdownUrl, content);
|
||||
|
||||
return content;
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{checked ? <Check /> : <Copy />}
|
||||
Copy Markdown
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewOptions({
|
||||
markdownUrl,
|
||||
githubUrl,
|
||||
}: {
|
||||
/**
|
||||
* A URL to the raw Markdown/MDX content of page
|
||||
*/
|
||||
markdownUrl: string;
|
||||
|
||||
/**
|
||||
* Source file URL on GitHub
|
||||
*/
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const items = useMemo(() => {
|
||||
const fullMarkdownUrl =
|
||||
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
|
||||
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Open in GitHub',
|
||||
href: githubUrl,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Open in Scira AI',
|
||||
href: `https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
width="910"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="20"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="20"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeWidth="30"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Open in ChatGPT',
|
||||
href: `https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: 'search',
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Open in Claude',
|
||||
href: `https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Anthropic</title>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Open in T3 Chat',
|
||||
href: `https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
];
|
||||
}, [githubUrl, markdownUrl]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'gap-2',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Open
|
||||
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4"
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
<ExternalLinkIcon className="text-fd-muted-foreground size-3.5 ms-auto" />
|
||||
</a>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubEditButton({ href }: { href: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'gap-2',
|
||||
})
|
||||
)}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5 text-fd-muted-foreground" />
|
||||
Edit on GitHub
|
||||
</a>
|
||||
)
|
||||
}
|
||||
1
docs/src/lib/cn.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { twMerge as cn } from 'tailwind-merge';
|
||||
34
docs/src/lib/i18n.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineI18n } from 'fumadocs-core/i18n';
|
||||
|
||||
export const i18n = defineI18n({
|
||||
languages: ['en', 'zh'],
|
||||
defaultLanguage: 'en',
|
||||
// Hide locale prefix for default language (en) so English content appears at root
|
||||
hideLocale: 'default-locale',
|
||||
parser: 'dir'
|
||||
});
|
||||
|
||||
export type Locale = (typeof i18n.languages)[number];
|
||||
|
||||
const localeSet = new Set(i18n.languages);
|
||||
|
||||
export function isLocale(value?: string): value is Locale {
|
||||
return Boolean(value && localeSet.has(value as Locale));
|
||||
}
|
||||
|
||||
export function resolveLocaleFromSlug(slug?: string[]): Locale {
|
||||
if (slug && slug.length > 0 && isLocale(slug[0])) {
|
||||
return slug[0];
|
||||
}
|
||||
return i18n.defaultLanguage;
|
||||
}
|
||||
|
||||
export function stripLocaleFromSlug(slug?: string[]): string[] {
|
||||
if (!slug || slug.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isLocale(slug[0])) {
|
||||
return slug.slice(1);
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
11
docs/src/lib/layout.shared.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
|
||||
export function baseOptions(_locale: string): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: 'VidBee',
|
||||
},
|
||||
i18n,
|
||||
};
|
||||
}
|
||||
31
docs/src/lib/source.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { docs } from 'fumadocs-mdx:collections/server';
|
||||
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: '/',
|
||||
i18n,
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export function getPageImage(page: InferPageType<typeof source>) {
|
||||
const localePrefix =
|
||||
page.locale && page.locale !== i18n.defaultLanguage ? [page.locale] : [];
|
||||
const segments = [...localePrefix, ...page.slugs, 'image.png'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/og/docs/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title}
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
9
docs/src/mdx-components.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
...components,
|
||||
};
|
||||
}
|
||||
9
docs/src/middleware.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
|
||||
export default createI18nMiddleware(i18n);
|
||||
|
||||
export const config = {
|
||||
// Note: Middleware doesn't run in static export, but kept for development
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
46
docs/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"fumadocs-mdx:collections/*": [
|
||||
".source/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
29
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.env.*
|
||||
54
extension/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# VidBee Video Downloader Extension
|
||||
|
||||
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.
|
||||
163
extension/assets/content.css
Normal file
@@ -0,0 +1,163 @@
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
203
extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const STATUS_TIMEOUT_MS = 800
|
||||
const INFO_TIMEOUT_MS = 60000
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const pendingRequests = new Map<string, Promise<void>>()
|
||||
const defaultIconPaths = {
|
||||
16: 'icon/16.png',
|
||||
32: 'icon/32.png',
|
||||
48: 'icon/48.png',
|
||||
128: 'icon/128.png'
|
||||
}
|
||||
const loadingIconPaths = {
|
||||
16: 'icon/icon-loading-16.png',
|
||||
32: 'icon/icon-loading-32.png',
|
||||
48: 'icon/icon-loading-48.png',
|
||||
128: 'icon/icon-loading-128.png'
|
||||
}
|
||||
const successIconPaths = {
|
||||
16: 'icon/icon-success-16.png',
|
||||
32: 'icon/icon-success-32.png',
|
||||
48: 'icon/icon-success-48.png',
|
||||
128: 'icon/icon-success-128.png'
|
||||
}
|
||||
|
||||
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
|
||||
const paths =
|
||||
status === 'loading'
|
||||
? loadingIconPaths
|
||||
: status === 'success'
|
||||
? successIconPaths
|
||||
: defaultIconPaths
|
||||
const options = tabId ? { path: paths, tabId } : { path: paths }
|
||||
void browser.action.setIcon(options)
|
||||
}
|
||||
|
||||
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
|
||||
if (!response.ok) {
|
||||
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
|
||||
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
|
||||
const combined = [message, details].filter(Boolean).join('\n\n')
|
||||
throw new Error(combined || `Request failed: ${response.status}`)
|
||||
}
|
||||
return data as T
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('signal is aborted')) {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||
throw new Error('VidBee app not responding on this port.')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
const findAvailablePort = async (): Promise<number | null> => {
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
try {
|
||||
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
|
||||
return port
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
|
||||
const port = await findAvailablePort()
|
||||
if (!port) {
|
||||
throw new Error('VidBee app not found on localhost.')
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
|
||||
if (!tokenResponse.token) {
|
||||
throw new Error('Failed to acquire token from VidBee.')
|
||||
}
|
||||
|
||||
return fetchJson<VideoInfo>(
|
||||
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
|
||||
tokenResponse.token
|
||||
)}`,
|
||||
INFO_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return {}
|
||||
return map
|
||||
}
|
||||
|
||||
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
if (now - entry.fetchedAt > CACHE_TTL_MS) {
|
||||
delete map[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
const cache = map[url]
|
||||
if (!cache) return null
|
||||
return cache
|
||||
}
|
||||
|
||||
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
map[cache.url] = cache
|
||||
await browser.storage.local.set({ videoInfoCacheByUrl: map })
|
||||
}
|
||||
|
||||
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
|
||||
if (pendingRequests.has(url)) {
|
||||
return pendingRequests.get(url) as Promise<void>
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const existing = await loadCache(url)
|
||||
if (existing?.status === 'ready') {
|
||||
setActionIcon('success', tabId)
|
||||
return
|
||||
}
|
||||
|
||||
setActionIcon('loading', tabId)
|
||||
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
|
||||
|
||||
try {
|
||||
const info = await requestVideoInfo(url)
|
||||
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
|
||||
setActionIcon('success', tabId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
|
||||
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
|
||||
setActionIcon('default', tabId)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(url, task)
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
pendingRequests.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
|
||||
if (message.type !== 'video-info:fetch' || !message.url) {
|
||||
return
|
||||
}
|
||||
void fetchAndCache(message.url, sender.tab?.id)
|
||||
})
|
||||
})
|
||||
317
extension/entrypoints/popup/App.css
Normal file
@@ -0,0 +1,317 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--fg-secondary: #757575;
|
||||
--border: #f0f0f0;
|
||||
--accent: #000000;
|
||||
--error: #e00000;
|
||||
--success: #00c853;
|
||||
--warning: #ffd600;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 360px;
|
||||
min-height: 200px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--border);
|
||||
}
|
||||
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
|
||||
.status-dot.ok { background-color: var(--success); }
|
||||
.status-dot.error { background-color: var(--error); }
|
||||
|
||||
.video-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.video-details h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 90px;
|
||||
height: 50px;
|
||||
background: var(--border);
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.formats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.format-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Sticky headers for long lists */
|
||||
.sticky-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
padding: 4px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.format-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.format-table th {
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.format-table td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.format-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-id { width: 50px; color: var(--fg-secondary); }
|
||||
.col-ext { width: 50px; }
|
||||
.col-size { width: 60px; text-align: right; }
|
||||
.format-table th.col-size { text-align: right; }
|
||||
|
||||
.empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
padding: 12px 0;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
background: rgba(224, 0, 0, 0.05);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.primary-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid var(--border);
|
||||
border-top-color: var(--fg);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
473
extension/entrypoints/popup/App.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
const isValidHttpUrl = (value?: string): boolean => {
|
||||
if (!value) return false
|
||||
return value.startsWith('http://') || value.startsWith('https://')
|
||||
}
|
||||
|
||||
const formatDuration = (value?: number): string => {
|
||||
if (!value || value <= 0) return 'Unknown'
|
||||
const totalSeconds = Math.round(value)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
|
||||
}
|
||||
|
||||
const formatBytes = (value?: number): string => {
|
||||
if (!value || value <= 0) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const isVideoFormat = (format: VideoFormat): boolean => {
|
||||
if (format.vcodec && format.vcodec !== 'none') {
|
||||
return true
|
||||
}
|
||||
return Boolean(format.resolution || format.width || format.height)
|
||||
}
|
||||
|
||||
const isAudioFormat = (format: VideoFormat): boolean => {
|
||||
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
type VideoGroup = {
|
||||
label: string
|
||||
height: number
|
||||
formats: VideoFormat[]
|
||||
}
|
||||
|
||||
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return null
|
||||
const cached = map[url]
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
|
||||
return cached
|
||||
}
|
||||
|
||||
const sanitizeError = (error: string): string => {
|
||||
const message = error.toLowerCase()
|
||||
if (
|
||||
message.includes('localhost') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('network') ||
|
||||
message.includes('connect') ||
|
||||
message.includes('failed to request')
|
||||
) {
|
||||
return 'Client connection failed'
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [info, setInfo] = useState<VideoInfo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState<string>('')
|
||||
const [retryTrigger, setRetryTrigger] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const targetState = { url: '' }
|
||||
|
||||
const handleStorageChange = (
|
||||
changes: Record<string, browser.storage.StorageChange>,
|
||||
areaName: string
|
||||
) => {
|
||||
if (!active || areaName !== 'local') return
|
||||
const change = changes.videoInfoCacheByUrl
|
||||
if (!change?.newValue) return
|
||||
|
||||
const map = change.newValue as Record<string, VideoInfoCacheEntry>
|
||||
const next = map[targetState.url]
|
||||
if (!next) return
|
||||
|
||||
if (next.status === 'ready' && next.info) {
|
||||
setInfo(next.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'error' && next.error) {
|
||||
setError(sanitizeError(next.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'pending') {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange)
|
||||
|
||||
const loadInfo = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setInfo(null)
|
||||
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
if (!isValidHttpUrl(tab?.url)) {
|
||||
setError('Please open a valid video page first.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = tab.url as string
|
||||
targetState.url = targetUrl
|
||||
setCurrentUrl(targetUrl)
|
||||
|
||||
const cached = await loadCachedInfo(targetUrl)
|
||||
const shouldBypassCache = retryTrigger > 0
|
||||
if (cached && !shouldBypassCache) {
|
||||
if (cached.status === 'ready' && cached.info) {
|
||||
setInfo(cached.info)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (cached.status === 'error' && cached.error) {
|
||||
setError(sanitizeError(cached.error))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'video-info:fetch',
|
||||
url: targetUrl
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to request video info.'
|
||||
setError(sanitizeError(message))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const latest = await loadCachedInfo(targetUrl)
|
||||
if (latest && latest.status === 'ready' && latest.info) {
|
||||
setInfo(latest.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (latest && latest.status === 'error' && latest.error) {
|
||||
setError(sanitizeError(latest.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadInfo()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
browser.storage.onChanged.removeListener(handleStorageChange)
|
||||
}
|
||||
}, [retryTrigger])
|
||||
|
||||
const formats = useMemo(() => info?.formats ?? [], [info])
|
||||
const groupedFormats = useMemo(() => {
|
||||
const video: VideoFormat[] = []
|
||||
const audio: VideoFormat[] = []
|
||||
const other: VideoFormat[] = []
|
||||
|
||||
for (const format of formats) {
|
||||
if (isVideoFormat(format)) {
|
||||
video.push(format)
|
||||
} else if (isAudioFormat(format)) {
|
||||
audio.push(format)
|
||||
} else {
|
||||
other.push(format)
|
||||
}
|
||||
}
|
||||
|
||||
return { video, audio, other }
|
||||
}, [formats])
|
||||
|
||||
const groupedVideoFormats = useMemo(() => {
|
||||
const raw = groupedFormats.video
|
||||
if (!raw.length) return []
|
||||
|
||||
const groups: Record<number, VideoFormat[]> = {}
|
||||
const noHeight: VideoFormat[] = []
|
||||
|
||||
for (const f of raw) {
|
||||
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
|
||||
const heightVal = h ? Number(h) : 0
|
||||
|
||||
if (heightVal > 0) {
|
||||
if (!groups[heightVal]) groups[heightVal] = []
|
||||
groups[heightVal].push(f)
|
||||
} else {
|
||||
noHeight.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedLabels = Object.keys(groups)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
const result: VideoGroup[] = sortedLabels.map((h) => ({
|
||||
label: `${h}p`,
|
||||
height: h,
|
||||
formats: groups[h].sort((a, b) => {
|
||||
const sa = a.filesize || a.filesize_approx || 0
|
||||
const sb = b.filesize || b.filesize_approx || 0
|
||||
return sb - sa
|
||||
})
|
||||
}))
|
||||
|
||||
if (noHeight.length > 0) {
|
||||
result.push({
|
||||
label: 'Other',
|
||||
height: 0,
|
||||
formats: noHeight
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot loading" /> Working
|
||||
</span>
|
||||
)
|
||||
if (error)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot error" /> Error
|
||||
</span>
|
||||
)
|
||||
if (info)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot ok" /> Ready
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot" /> Idle
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>VidBee</h1>
|
||||
{renderStatus()}
|
||||
</header>
|
||||
{loading && (
|
||||
<div className="loading-container">
|
||||
<div className="spinner" />
|
||||
<div className="loading-text">Analyzing video...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="error-container">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loading && !error && info && (
|
||||
<>
|
||||
<section className="video-info">
|
||||
<div className="video-details">
|
||||
<h2>{info.title || 'Untitled video'}</h2>
|
||||
<div className="meta-row">
|
||||
<span>{formatDuration(info.duration)}</span>
|
||||
<span>•</span>
|
||||
<span>{formats.length} formats</span>
|
||||
</div>
|
||||
</div>
|
||||
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
|
||||
</section>
|
||||
|
||||
<button type="button" className="primary-button" onClick={handleOpenClient}>
|
||||
Download with VidBee
|
||||
</button>
|
||||
|
||||
<section className="formats-section">
|
||||
{groupedVideoFormats.map((group) => (
|
||||
<div className="format-group" key={group.label}>
|
||||
<div className="group-title sticky-title">{group.label}</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.formats.map((f) => (
|
||||
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
|
||||
<div className="empty-state">No compatible formats.</div>
|
||||
)}
|
||||
|
||||
{groupedFormats.audio.length > 0 && (
|
||||
<div className="format-group">
|
||||
<div className="group-title">Audio Only</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedFormats.audio.map((f) => (
|
||||
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
13
extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Popup Title</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
extension/entrypoints/popup/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (root) {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
28
extension/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vidbee-extension",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"typescript": "^5.9.3",
|
||||
"wxt": "^0.20.6"
|
||||
}
|
||||
}
|
||||
3541
extension/pnpm-lock.yaml
generated
Normal file
14
extension/public/_locales/en/messages.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "VidBee Video Downloader"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Download videos from over 1,000 websites with VidBee."
|
||||
},
|
||||
"downloadWithVidBee": {
|
||||
"message": "Download with VidBee"
|
||||
},
|
||||
"hideButton": {
|
||||
"message": "Hide"
|
||||
}
|
||||
}
|
||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/public/icon/48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-loading-128.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
extension/public/icon/icon-loading-16.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
extension/public/icon/icon-loading-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-loading-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-success-128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
extension/public/icon/icon-success-16.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
extension/public/icon/icon-success-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-success-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
13
extension/wxt.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'wxt'
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
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']
|
||||
}
|
||||
})
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"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",
|
||||
@@ -33,7 +34,9 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-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",
|
||||
@@ -47,6 +50,7 @@
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"electron-ipc-decorator": "^0.2.0",
|
||||
|
||||
94
pnpm-lock.yaml
generated
@@ -38,9 +38,15 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.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-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@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-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)
|
||||
@@ -80,6 +86,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@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)
|
||||
dayjs:
|
||||
specifier: ^1.11.18
|
||||
version: 1.11.18
|
||||
@@ -999,6 +1008,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
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-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -1064,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:
|
||||
@@ -1896,6 +1931,12 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -4481,6 +4522,29 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@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-dismissable-layer': 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-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-focus-scope': 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-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-popper': 1.2.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-portal': 1.1.9(@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-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-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-popper@1.2.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:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -4538,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
|
||||
@@ -5429,6 +5511,18 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@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/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-dialog': 1.1.15(@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-id': 1.1.1(@types/react@19.2.2)(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)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
3
resources/.gitignore
vendored
@@ -6,6 +6,9 @@ ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
ffmpeg/
|
||||
ffprobe
|
||||
ffprobe.exe
|
||||
deno.exe
|
||||
deno
|
||||
|
||||
|
||||
@@ -48,27 +48,27 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
|
||||
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
|
||||
```
|
||||
|
||||
## 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
@@ -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.')
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
@@ -104,40 +113,101 @@ function ensureDir(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeUnlink(filePath) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadHeaders(url) {
|
||||
const headers = {
|
||||
'User-Agent': 'vidbee-setup',
|
||||
Accept: '*/*'
|
||||
}
|
||||
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
|
||||
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
let downloadedBytes = 0
|
||||
|
||||
protocol
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
reject(err)
|
||||
safeUnlink(dest)
|
||||
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 ${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()
|
||||
})
|
||||
})
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy(new Error('Download timeout'))
|
||||
})
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
log(`Download error for ${url}: ${err.message}`, 'error')
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
lastError = error
|
||||
safeUnlink(dest)
|
||||
if (attempt < retries) {
|
||||
const backoff = delayMs * attempt
|
||||
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
@@ -258,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') {
|
||||
@@ -290,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
|
||||
}
|
||||
@@ -299,9 +410,14 @@ async function downloadYtDlp(config) {
|
||||
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempPath)
|
||||
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)) {
|
||||
@@ -312,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 {
|
||||
@@ -335,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')
|
||||
@@ -342,7 +486,7 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -352,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)
|
||||
@@ -372,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
|
||||
@@ -397,7 +581,7 @@ async function downloadFfmpegMac(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -408,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
|
||||
@@ -421,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 {
|
||||
@@ -444,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')
|
||||
@@ -451,7 +676,7 @@ async function downloadFfmpegLinux(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempTar)
|
||||
await downloadFileWithRetry(downloadUrl, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
@@ -462,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
|
||||
@@ -488,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
|
||||
}
|
||||
@@ -498,7 +740,7 @@ async function downloadDenoRuntime() {
|
||||
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting Deno runtime...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -509,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)
|
||||
|
||||
@@ -20,18 +20,28 @@ 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 && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
if (format && audioFormat === '') {
|
||||
return format
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
if (!audioFormat || audioFormat === 'best') {
|
||||
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
|
||||
return 'bestvideo+bestaudio'
|
||||
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
|
||||
return 'bestvideo+bestaudio/best'
|
||||
}
|
||||
return `bestvideo+${audioFormat}`
|
||||
}
|
||||
@@ -58,13 +68,29 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
||||
return format
|
||||
}
|
||||
|
||||
const isBilibiliUrl = (url: string): boolean => {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
return (
|
||||
host === 'bilibili.com' ||
|
||||
host.endsWith('.bilibili.com') ||
|
||||
host === 'b23.tv' ||
|
||||
host.endsWith('.b23.tv') ||
|
||||
host === 'bili.tv' ||
|
||||
host.endsWith('.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')
|
||||
@@ -73,15 +99,16 @@ export const buildDownloadArgs = (
|
||||
if (options.type === 'video') {
|
||||
const formatSelector = resolveVideoFormatSelector(options)
|
||||
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
|
||||
@@ -91,10 +118,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
|
||||
@@ -105,8 +150,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') {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -2,10 +2,21 @@ import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
type BrowserWindowConstructorOptions,
|
||||
ipcMain,
|
||||
protocol,
|
||||
shell
|
||||
} from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
import {
|
||||
buildAudioFormatPreference,
|
||||
buildVideoFormatPreference
|
||||
} from '../shared/utils/format-preferences'
|
||||
import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
@@ -13,6 +24,7 @@ import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||
import { subscriptionManager } from './lib/subscription-manager'
|
||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyAutoLaunchSetting } from './utils/auto-launch'
|
||||
@@ -39,11 +51,13 @@ protocol.registerSchemesAsPrivileged([
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
let isYtdlpReady = false
|
||||
interface DeepLinkData {
|
||||
url: string
|
||||
type: 'single' | 'playlist'
|
||||
}
|
||||
const pendingDeepLinkUrls: DeepLinkData[] = []
|
||||
const pendingOneClickDownloads: DeepLinkData[] = []
|
||||
let isRendererReady = false
|
||||
|
||||
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
|
||||
@@ -111,6 +125,10 @@ const handleDeepLinkUrl = (rawUrl: string): void => {
|
||||
log.warn('Ignored unsupported deep link:', rawUrl)
|
||||
return
|
||||
}
|
||||
if (settingsManager.get('oneClickDownload')) {
|
||||
queueOneClickDownload(data)
|
||||
return
|
||||
}
|
||||
deliverDeepLink(data)
|
||||
}
|
||||
|
||||
@@ -129,6 +147,7 @@ subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
|
||||
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
width: 1200,
|
||||
@@ -168,6 +187,9 @@ export function createWindow(): void {
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
if (shouldStartHidden) {
|
||||
return
|
||||
}
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
@@ -190,11 +212,57 @@ export function createWindow(): void {
|
||||
flushPendingDeepLinks()
|
||||
})
|
||||
|
||||
// Setup error handling for renderer process
|
||||
setupRendererErrorHandling()
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
|
||||
function setupRendererErrorHandling(): void {
|
||||
if (!mainWindow) return
|
||||
|
||||
// Handle uncaught exceptions in renderer process
|
||||
mainWindow.webContents.on('unresponsive', () => {
|
||||
log.error('Renderer process became unresponsive')
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('responsive', () => {
|
||||
log.info('Renderer process became responsive again')
|
||||
})
|
||||
|
||||
// Listen for renderer errors via IPC
|
||||
ipcMain.on('error:renderer', (_event, errorData) => {
|
||||
log.error('Renderer error received:', errorData)
|
||||
|
||||
// Log detailed error information
|
||||
if (errorData.error) {
|
||||
log.error('Error name:', errorData.error.name)
|
||||
log.error('Error message:', errorData.error.message)
|
||||
if (errorData.error.stack) {
|
||||
log.error('Error stack:', errorData.error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorData.errorInfo?.componentStack) {
|
||||
log.error('Component stack:', errorData.errorInfo.componentStack)
|
||||
}
|
||||
|
||||
if (errorData.context) {
|
||||
log.error('Error context:', errorData.context)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupDownloadEvents(): void {
|
||||
downloadEngine.on('download-queued', (item: unknown) => {
|
||||
mainWindow?.webContents.send('download:queued', item)
|
||||
})
|
||||
|
||||
downloadEngine.on('download-updated', (id: string, updates: unknown) => {
|
||||
mainWindow?.webContents.send('download:updated', { id, updates })
|
||||
})
|
||||
|
||||
downloadEngine.on('download-started', (id: string) => {
|
||||
mainWindow?.webContents.send('download:started', id)
|
||||
})
|
||||
@@ -216,6 +284,61 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
const createDownloadId = (): string =>
|
||||
`download_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`
|
||||
|
||||
const queueOneClickDownload = (data: DeepLinkData): void => {
|
||||
if (!isYtdlpReady) {
|
||||
pendingOneClickDownloads.push(data)
|
||||
return
|
||||
}
|
||||
void startOneClickDownload(data)
|
||||
}
|
||||
|
||||
const flushPendingOneClickDownloads = (): void => {
|
||||
if (!isYtdlpReady || pendingOneClickDownloads.length === 0) {
|
||||
return
|
||||
}
|
||||
const pending = pendingOneClickDownloads.splice(0, pendingOneClickDownloads.length)
|
||||
for (const data of pending) {
|
||||
void startOneClickDownload(data)
|
||||
}
|
||||
}
|
||||
|
||||
const startOneClickDownload = async (data: DeepLinkData): Promise<void> => {
|
||||
try {
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadType = settings.oneClickDownloadType ?? 'video'
|
||||
const format =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
if (data.type === 'playlist') {
|
||||
const result = await downloadEngine.startPlaylistDownload({
|
||||
url: data.url,
|
||||
type: downloadType,
|
||||
format
|
||||
})
|
||||
log.info('One-click playlist download queued:', {
|
||||
url: data.url,
|
||||
count: result.totalCount
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const downloadId = createDownloadId()
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url: data.url,
|
||||
type: downloadType,
|
||||
format
|
||||
})
|
||||
log.info('One-click download queued:', { id: downloadId, url: data.url })
|
||||
} catch (error) {
|
||||
log.error('Failed to start one-click download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRequestPath(requestUrl: URL): string {
|
||||
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
|
||||
const trimmedLeading = rawPath.replace(/^\/+/, '')
|
||||
@@ -314,12 +437,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')
|
||||
@@ -379,6 +496,17 @@ app.whenReady().then(async () => {
|
||||
// and ignore CommandOrControl + R in production.
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
|
||||
// Enable F12 to toggle DevTools in both development and production
|
||||
window.webContents.on('before-input-event', (_, input) => {
|
||||
if (input.key === 'F12') {
|
||||
if (window.webContents.isDevToolsOpened()) {
|
||||
window.webContents.closeDevTools()
|
||||
} else {
|
||||
window.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
||||
@@ -397,11 +525,19 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
log.info('Initializing yt-dlp...')
|
||||
await ytdlpManager.initialize()
|
||||
isYtdlpReady = true
|
||||
log.info('yt-dlp initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
if (isYtdlpReady) {
|
||||
downloadEngine.restoreActiveDownloads()
|
||||
flushPendingOneClickDownloads()
|
||||
}
|
||||
|
||||
await startExtensionApiServer()
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
@@ -417,14 +553,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
|
||||
@@ -449,6 +598,7 @@ app.on('window-all-closed', () => {
|
||||
// Cleanup tray on quit
|
||||
app.on('will-quit', () => {
|
||||
destroyTray()
|
||||
void stopExtensionApiServer()
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
|
||||
import { AppService } from './services/app-service'
|
||||
import { BrowserCookiesService } from './services/browser-cookies-service'
|
||||
import { DownloadService } from './services/download-service'
|
||||
import { FileSystemService } from './services/file-system-service'
|
||||
import { HistoryService } from './services/history-service'
|
||||
@@ -12,6 +13,7 @@ import { WindowService } from './services/window-service'
|
||||
// Create services with automatic type inference
|
||||
export const services = createServices([
|
||||
AppService,
|
||||
BrowserCookiesService,
|
||||
DownloadService,
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
|
||||
@@ -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()
|
||||
|
||||
270
src/main/ipc/services/browser-cookies-service.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { resolvePathWithHome } from '../../utils/path-helpers'
|
||||
|
||||
class BrowserCookiesService extends IpcService {
|
||||
static readonly groupName = 'browserCookies'
|
||||
|
||||
private buildValidationResult(valid: boolean, reason?: string) {
|
||||
if (valid) {
|
||||
return { valid }
|
||||
}
|
||||
return { valid, reason }
|
||||
}
|
||||
|
||||
private isDirectory(target: string): boolean {
|
||||
try {
|
||||
return fs.statSync(target).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private pickFirstDirectory(paths: string[]): string {
|
||||
for (const candidate of paths) {
|
||||
if (this.isDirectory(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private normalizeProfileInput(value: string): string {
|
||||
return value.trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
|
||||
if (platform === 'win32') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [
|
||||
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
|
||||
]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
|
||||
]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
|
||||
]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
|
||||
]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
|
||||
}
|
||||
if (browser === 'safari') {
|
||||
return [path.join(homeDir, 'Library', 'Safari')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, '.config', 'microsoft-edge')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, '.config', 'google-chrome')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, '.config', 'chromium')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, '.config', 'vivaldi')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [path.join(homeDir, '.config', 'naver-whale')]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [path.join(homeDir, '.config', 'opera')]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'freebsd') {
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
|
||||
const base = baseDirs[0]
|
||||
if (!base) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
|
||||
return base
|
||||
}
|
||||
|
||||
return path.join(base, 'Default')
|
||||
}
|
||||
|
||||
private findFirefoxProfilePath(profilesDir: string): string {
|
||||
if (!this.isDirectory(profilesDir)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(profilesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const preferred =
|
||||
entries.find((name) => name.endsWith('.default-release')) ??
|
||||
entries.find((name) => name.endsWith('.default')) ??
|
||||
entries[0]
|
||||
|
||||
return preferred ? path.join(profilesDir, preferred) : ''
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getBrowserProfilePath(_context: IpcContext, browser: string): string {
|
||||
if (!browser || browser === 'none') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
const platform = os.platform()
|
||||
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
|
||||
|
||||
if (browser === 'firefox') {
|
||||
const profilesDir = baseDirs[0]
|
||||
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
|
||||
return profilePath || fallbackPath
|
||||
}
|
||||
|
||||
if (browser === 'safari') {
|
||||
const safariPath = baseDirs[0]
|
||||
if (safariPath && this.isDirectory(safariPath)) {
|
||||
return safariPath
|
||||
}
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
if (baseDirs.length === 0) {
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
let detectedPath = ''
|
||||
for (const baseDir of baseDirs) {
|
||||
if (!baseDir) {
|
||||
continue
|
||||
}
|
||||
const candidates =
|
||||
browser === 'opera'
|
||||
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||
detectedPath = this.pickFirstDirectory(candidates)
|
||||
if (detectedPath) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return detectedPath || fallbackPath
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
validateBrowserProfilePath(
|
||||
_context: IpcContext,
|
||||
browser: string,
|
||||
profilePath: string
|
||||
): { valid: boolean; reason?: string } {
|
||||
if (!browser || browser === 'none') {
|
||||
return this.buildValidationResult(false, 'browserUnsupported')
|
||||
}
|
||||
|
||||
const normalizedInput = this.normalizeProfileInput(profilePath)
|
||||
if (!normalizedInput) {
|
||||
return this.buildValidationResult(false, 'empty')
|
||||
}
|
||||
|
||||
const resolvedInput = resolvePathWithHome(normalizedInput)
|
||||
if (resolvedInput && this.isDirectory(resolvedInput)) {
|
||||
return this.buildValidationResult(true)
|
||||
}
|
||||
|
||||
const looksLikePath =
|
||||
resolvedInput &&
|
||||
(path.isAbsolute(resolvedInput) ||
|
||||
resolvedInput.includes('/') ||
|
||||
resolvedInput.includes('\\'))
|
||||
if (looksLikePath) {
|
||||
return this.buildValidationResult(false, 'pathNotFound')
|
||||
}
|
||||
|
||||
const platform = os.platform()
|
||||
const homeDir = os.homedir()
|
||||
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||
if (baseDirs.length === 0) {
|
||||
return this.buildValidationResult(false, 'browserUnsupported')
|
||||
}
|
||||
for (const baseDir of baseDirs) {
|
||||
if (!baseDir) {
|
||||
continue
|
||||
}
|
||||
const candidate = path.join(baseDir, normalizedInput)
|
||||
if (this.isDirectory(candidate)) {
|
||||
return this.buildValidationResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildValidationResult(false, 'profileNotFound')
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserCookiesService }
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
|
||||
@@ -29,10 +28,6 @@ class SettingsService extends IpcService {
|
||||
if (key === 'launchAtLogin') {
|
||||
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -55,10 +50,6 @@ class SettingsService extends IpcService {
|
||||
if (typeof settings.launchAtLogin === 'boolean') {
|
||||
applyAutoLaunchSetting(settings.launchAtLogin)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -66,7 +57,6 @@ class SettingsService extends IpcService {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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'),
|
||||
description: text('description'),
|
||||
channel: text('channel'),
|
||||
uploader: text('uploader'),
|
||||
|
||||
@@ -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'
|
||||
@@ -36,6 +42,21 @@ interface DownloadProcess {
|
||||
process: YTDlpEventEmitter
|
||||
}
|
||||
|
||||
const formatYtDlpCommand = (args: string[]): string => {
|
||||
const quoted = args.map((arg) => {
|
||||
if (arg === '') {
|
||||
return '""'
|
||||
}
|
||||
if (/[\s"'\\]/.test(arg)) {
|
||||
return `"${arg.replace(/(["\\])/g, '\\$1')}"`
|
||||
}
|
||||
return arg
|
||||
})
|
||||
return `yt-dlp ${quoted.join(' ')}`
|
||||
}
|
||||
|
||||
const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath)
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
@@ -67,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,
|
||||
@@ -152,9 +226,51 @@ 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
|
||||
private prefetchTasks: Map<string, Promise<VideoInfo | null>> = new Map()
|
||||
private prefetchedInfo: Map<string, VideoInfo> = new Map()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -164,45 +280,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)
|
||||
@@ -268,6 +356,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()
|
||||
@@ -480,7 +652,7 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
// Add to queue
|
||||
this.queue.add(downloadId, downloadOptions, {
|
||||
const queueItem: DownloadItem = {
|
||||
id: downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
@@ -492,7 +664,9 @@ class DownloadEngine extends EventEmitter {
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
}
|
||||
this.queue.add(downloadId, downloadOptions, queueItem)
|
||||
this.emit('download-queued', { ...queueItem })
|
||||
|
||||
this.upsertHistoryEntry(downloadId, downloadOptions, {
|
||||
title: entry.title,
|
||||
@@ -541,6 +715,7 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
@@ -558,6 +733,45 @@ class DownloadEngine extends EventEmitter {
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
|
||||
this.emit('download-queued', { ...item })
|
||||
void this.prefetchVideoInfo(id, options)
|
||||
}
|
||||
|
||||
private async prefetchVideoInfo(id: string, options: DownloadOptions): Promise<void> {
|
||||
const url = options.url?.trim()
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
if (this.prefetchTasks.has(id) || this.prefetchedInfo.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const info = await this.getVideoInfo(url)
|
||||
this.prefetchedInfo.set(id, info)
|
||||
this.updateDownloadInfo(id, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
description: info.description,
|
||||
uploader: info.uploader,
|
||||
viewCount: info.view_count
|
||||
})
|
||||
return info
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to prefetch video info for ID:', id, error)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
this.prefetchTasks.set(id, task)
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
this.prefetchTasks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
|
||||
@@ -578,12 +792,11 @@ 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
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
const info = await this.getVideoInfo(options.url)
|
||||
videoInfo = info
|
||||
|
||||
const applyVideoInfo = (info: VideoInfo) => {
|
||||
availableFormats = Array.isArray(info.formats) ? info.formats : []
|
||||
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
|
||||
|
||||
@@ -591,6 +804,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,
|
||||
@@ -612,8 +833,33 @@ class DownloadEngine extends EventEmitter {
|
||||
// Store only essential download info
|
||||
selectedFormat
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
|
||||
videoInfo = this.prefetchedInfo.get(id)
|
||||
if (!videoInfo) {
|
||||
const prefetchTask = this.prefetchTasks.get(id)
|
||||
if (prefetchTask) {
|
||||
try {
|
||||
await prefetchTask
|
||||
} catch {
|
||||
// Ignore prefetch failures, download will attempt again below.
|
||||
}
|
||||
videoInfo = this.prefetchedInfo.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (videoInfo) {
|
||||
this.prefetchedInfo.delete(id)
|
||||
applyVideoInfo(videoInfo)
|
||||
} else {
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
const info = await this.getVideoInfo(options.url)
|
||||
videoInfo = info
|
||||
applyVideoInfo(info)
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.customDownloadPath?.trim()) {
|
||||
@@ -729,9 +975,14 @@ class DownloadEngine extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
args.push('--ffmpeg-location', ffmpegPath)
|
||||
const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)
|
||||
args.push('--ffmpeg-location', ffmpegLocation)
|
||||
args.push(urlArg)
|
||||
|
||||
const ytDlpCommand = formatYtDlpCommand(args)
|
||||
this.updateDownloadInfo(id, { ytDlpCommand })
|
||||
scopedLoggers.download.info('yt-dlp command:', ytDlpCommand)
|
||||
|
||||
const controller = new AbortController()
|
||||
const ytdlpProcess = ytdlp.exec(args, {
|
||||
signal: controller.signal
|
||||
@@ -739,6 +990,8 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
||||
|
||||
this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })
|
||||
this.scheduleSessionPersist()
|
||||
this.emit('download-started', id)
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
@@ -770,19 +1023,50 @@ 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)
|
||||
}
|
||||
)
|
||||
|
||||
// Handle yt-dlp events to capture format info
|
||||
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
||||
if (
|
||||
eventType === 'postprocess' ||
|
||||
eventData.toLowerCase().includes('merging formats') ||
|
||||
eventData.toLowerCase().includes('post-process')
|
||||
) {
|
||||
const snapshot = this.queue.getItemDetails(id)
|
||||
if (snapshot?.item.status !== 'processing') {
|
||||
this.updateDownloadInfo(id, { status: 'processing' })
|
||||
}
|
||||
}
|
||||
|
||||
// Look for format selection messages
|
||||
if (eventType === 'info' && eventData.includes('format')) {
|
||||
// Extract format info from yt-dlp output
|
||||
@@ -829,7 +1113,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)
|
||||
@@ -990,6 +1275,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)
|
||||
|
||||
@@ -1051,6 +1402,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.ytDlpCommand !== undefined) {
|
||||
historyUpdates.ytDlpCommand = updates.ytDlpCommand
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
@@ -1058,6 +1412,41 @@ class DownloadEngine extends EventEmitter {
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.emit('download-updated', id, { ...updates })
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -1115,6 +1504,7 @@ class DownloadEngine extends EventEmitter {
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
ytDlpCommand: updates.ytDlpCommand,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
|
||||
@@ -82,6 +82,28 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getActiveItems(): DownloadItem[] {
|
||||
return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item }))
|
||||
}
|
||||
|
||||
getQueuedItems(): DownloadItem[] {
|
||||
return this.queue.map((item) => ({ ...item.item }))
|
||||
}
|
||||
|
||||
getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||
return Array.from(this.activeDownloads.values()).map((entry) => ({
|
||||
options: { ...entry.options },
|
||||
item: { ...entry.item }
|
||||
}))
|
||||
}
|
||||
|
||||
getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||
return this.queue.map((entry) => ({
|
||||
options: { ...entry.options },
|
||||
item: { ...entry.item }
|
||||
}))
|
||||
}
|
||||
|
||||
isDownloading(id: string): boolean {
|
||||
return this.activeDownloads.has(id)
|
||||
}
|
||||
|
||||
67
src/main/lib/download-session-store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import type { DownloadItem, DownloadOptions } from '../../shared/types'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
export interface DownloadSessionItem {
|
||||
id: string
|
||||
options: DownloadOptions
|
||||
item: DownloadItem
|
||||
}
|
||||
|
||||
interface DownloadSessionPayload {
|
||||
version: 1
|
||||
updatedAt: number
|
||||
items: DownloadSessionItem[]
|
||||
}
|
||||
|
||||
const SESSION_FILE_NAME = 'download-session.json'
|
||||
|
||||
const getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME)
|
||||
|
||||
const isValidItem = (item: DownloadSessionItem): boolean =>
|
||||
Boolean(item?.id && item.options && item.item)
|
||||
|
||||
export const loadDownloadSession = (): DownloadSessionItem[] => {
|
||||
const filePath = getSessionFilePath()
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const payload = JSON.parse(raw) as DownloadSessionPayload
|
||||
if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) {
|
||||
return []
|
||||
}
|
||||
return payload.items.filter(isValidItem)
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to load download session:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const saveDownloadSession = (items: DownloadSessionItem[]): void => {
|
||||
const filePath = getSessionFilePath()
|
||||
if (items.length === 0) {
|
||||
try {
|
||||
fs.rmSync(filePath, { force: true })
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to clear download session:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload: DownloadSessionPayload = {
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
items
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8')
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to save download session:', error)
|
||||
}
|
||||
}
|
||||
@@ -28,46 +28,58 @@ class FfmpegManager {
|
||||
|
||||
private async findFfmpegBinary(): Promise<string> {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const createDownloadHistoryTableSql = sql`
|
||||
completed_at INTEGER,
|
||||
sort_key INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
yt_dlp_command TEXT,
|
||||
description TEXT,
|
||||
channel TEXT,
|
||||
uploader TEXT,
|
||||
@@ -218,7 +219,12 @@ class HistoryManager {
|
||||
}
|
||||
|
||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
const requiredColumns = ['yt_dlp_command']
|
||||
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
const missingRequired = requiredColumns.some(
|
||||
(columnName) => !columns.some((column) => column.name === columnName)
|
||||
)
|
||||
const needsRebuild = hasDeprecated || missingRequired
|
||||
if (needsRebuild) {
|
||||
this.rebuildDownloadHistoryTable()
|
||||
}
|
||||
@@ -387,6 +393,7 @@ class HistoryManager {
|
||||
completedAt: item.completedAt ?? null,
|
||||
sortKey: item.completedAt ?? item.downloadedAt,
|
||||
error: item.error ?? null,
|
||||
ytDlpCommand: item.ytDlpCommand ?? null,
|
||||
description: item.description ?? null,
|
||||
channel: item.channel ?? null,
|
||||
uploader: item.uploader ?? null,
|
||||
@@ -433,6 +440,7 @@ class HistoryManager {
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
ytDlpCommand: row.ytDlpCommand ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -19,6 +23,11 @@ type ParserItem = {
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
content?: string
|
||||
contentSnippet?: string
|
||||
contentEncoded?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
@@ -41,24 +50,19 @@ type FeedItem = {
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
const parser = new Parser<Record<string, never>, ParserItem>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
['enclosure', 'enclosure'],
|
||||
['content:encoded', 'contentEncoded'],
|
||||
['description', 'description']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||
return `sub_${base}`
|
||||
@@ -167,7 +171,7 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
const intervalHours = 3 // Default check interval: 3 hours
|
||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||
this.timer = setTimeout(() => {
|
||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||
@@ -240,13 +244,18 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
const coverUrl = this.resolveSubscriptionCover(
|
||||
feed,
|
||||
normalizedItems,
|
||||
feedItems as ParserItem[]
|
||||
)
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
coverUrl: coverUrl ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
@@ -366,6 +375,74 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
// Try to parse an image from HTML fields
|
||||
const htmlCandidates = [
|
||||
item.content,
|
||||
item.contentEncoded,
|
||||
item.description,
|
||||
item.summary,
|
||||
item.contentSnippet
|
||||
]
|
||||
for (const html of htmlCandidates) {
|
||||
const imageUrl = this.extractImageFromHtml(html)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private resolveSubscriptionCover(
|
||||
feed: Parser.Output<ParserItem>,
|
||||
items: FeedItem[],
|
||||
rawItems: ParserItem[]
|
||||
): string | undefined {
|
||||
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
|
||||
if (feedImageUrl) {
|
||||
return feedImageUrl
|
||||
}
|
||||
|
||||
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
|
||||
if (itunesImageUrl) {
|
||||
return itunesImageUrl
|
||||
}
|
||||
|
||||
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
|
||||
if (itemThumbnail) {
|
||||
return itemThumbnail
|
||||
}
|
||||
|
||||
for (const item of rawItems) {
|
||||
const thumbnail = this.resolveThumbnail(item)
|
||||
if (thumbnail) {
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private extractImageFromHtml(html?: string): string | undefined {
|
||||
if (!html) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const srcMatch = html.match(
|
||||
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
|
||||
)
|
||||
if (srcMatch?.[2]) {
|
||||
return srcMatch[2]
|
||||
}
|
||||
|
||||
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
|
||||
if (srcsetMatch?.[2]) {
|
||||
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
|
||||
if (firstCandidate) {
|
||||
return firstCandidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -408,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]))
|
||||
@@ -415,7 +497,8 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
type: downloadType,
|
||||
format: formatPreference,
|
||||
customDownloadPath: downloadDirectory,
|
||||
customFilenameTemplate: namingTemplate,
|
||||
tags,
|
||||
|
||||
191
src/main/local-api.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import crypto from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const TOKEN_TTL_MS = 60_000
|
||||
|
||||
type TokenRecord = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let server: http.Server | null = null
|
||||
let serverPort: number | null = null
|
||||
const tokens = new Map<string, TokenRecord>()
|
||||
|
||||
const isLoopbackAddress = (address?: string | null): boolean => {
|
||||
if (!address) return false
|
||||
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
||||
}
|
||||
|
||||
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const writeEmpty = (res: http.ServerResponse, status: number): void => {
|
||||
res.writeHead(status, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
|
||||
const issueToken = (): string => {
|
||||
const token = crypto.randomBytes(16).toString('hex')
|
||||
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
const consumeToken = (token?: string | null): boolean => {
|
||||
if (!token) return false
|
||||
const record = tokens.get(token)
|
||||
if (!record) return false
|
||||
if (Date.now() > record.expiresAt) {
|
||||
tokens.delete(token)
|
||||
return false
|
||||
}
|
||||
tokens.delete(token)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
||||
writeJson(res, 403, { error: 'Forbidden' })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
writeEmpty(res, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.url) {
|
||||
writeJson(res, 400, { error: 'Missing URL' })
|
||||
return
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, 'http://127.0.0.1')
|
||||
const pathname = requestUrl.pathname
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
writeJson(res, 405, { error: 'Method not allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/token') {
|
||||
const token = issueToken()
|
||||
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/video-info') {
|
||||
const token = requestUrl.searchParams.get('token')
|
||||
if (!consumeToken(token)) {
|
||||
writeJson(res, 401, { error: 'Invalid token' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = requestUrl.searchParams.get('url')
|
||||
if (!targetUrl || !targetUrl.trim()) {
|
||||
writeJson(res, 400, { error: 'Missing url' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
|
||||
writeJson(res, 200, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
formats: info.formats ?? []
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
const details =
|
||||
error instanceof Error
|
||||
? error.stack
|
||||
: typeof error === 'object' && error && 'stderr' in error
|
||||
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||
: undefined
|
||||
writeJson(res, 500, { error: message, details })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/status') {
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unhandled request error'
|
||||
writeJson(res, 500, { error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const startServerOnPort = (port: number): Promise<http.Server> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
void handleRequest(req, res)
|
||||
})
|
||||
|
||||
httpServer.once('error', (error) => {
|
||||
httpServer.close()
|
||||
reject(error)
|
||||
})
|
||||
|
||||
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
|
||||
})
|
||||
|
||||
export async function startExtensionApiServer(): Promise<number | null> {
|
||||
if (server && serverPort) {
|
||||
return serverPort
|
||||
}
|
||||
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
try {
|
||||
server = await startServerOnPort(port)
|
||||
const address = server.address() as AddressInfo | null
|
||||
serverPort = address?.port ?? port
|
||||
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
|
||||
return serverPort
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code !== 'EADDRINUSE') {
|
||||
log.warn('Extension API failed to start on port:', port, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function stopExtensionApiServer(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server?.close(() => resolve())
|
||||
})
|
||||
|
||||
server = null
|
||||
serverPort = null
|
||||
tokens.clear()
|
||||
}
|
||||
@@ -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()
|
||||
@@ -53,7 +51,11 @@ class SettingsManager {
|
||||
}
|
||||
|
||||
getAll(): AppSettings {
|
||||
return this.store.store
|
||||
return {
|
||||
...defaultSettings,
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH,
|
||||
...this.store.store
|
||||
}
|
||||
}
|
||||
|
||||
setAll(settings: Partial<AppSettings>): void {
|
||||
@@ -71,7 +73,6 @@ class SettingsManager {
|
||||
...defaultSettings,
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
@@ -84,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)
|
||||
}
|
||||
|
||||
2
src/preload/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -9,6 +8,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { 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'
|
||||
@@ -16,6 +17,7 @@ import { Settings } from './pages/Settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
import { updateAvailableAtom, updateReadyAtom } from './store/update'
|
||||
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||
|
||||
@@ -51,7 +53,9 @@ function AppContent() {
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const { t } = useTranslation()
|
||||
const setUpdateReady = useSetAtom(updateReadyAtom)
|
||||
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||
const { i18n } = useTranslation()
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
@@ -59,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] ?? '/'
|
||||
@@ -174,16 +180,46 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
resetDownloadState()
|
||||
setUpdateReady({
|
||||
ready: true,
|
||||
version: info.version
|
||||
})
|
||||
setUpdateAvailable({
|
||||
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) => {
|
||||
@@ -193,37 +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 is handled in About page only
|
||||
// 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)
|
||||
}
|
||||
}, [t])
|
||||
}, [i18n, setUpdateAvailable, setUpdateReady])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
@@ -239,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} />
|
||||
@@ -270,11 +284,13 @@ function AppContent() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||