Compare commits

...

66 Commits

Author SHA1 Message Date
Nexmoe
6f1c6ce59e chore: release v1.2.0 2026-01-17 13:58:17 +08:00
Nexmoe
e5d36da904 fix(ffmpeg): require ffmpeg/ffprobe bundle directory, closes #94 #106 #105 #69 #133 (#139)
* fix(ffmpeg): require bundled ffprobe directory

* ci(build): upload artifacts on PR

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

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

* fix(download): remove clear all history action

* feat(download): clear selection on escape

* feat(download): select full playlist groups

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

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

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

* fix(ui): add scroll areas to pages

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

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

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

* ci(macos): verify signing and notarization

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

* feat(settings): default embed on

* refactor(setup-dev-binaries): enhance download error handling and version checks for binaries
2026-01-11 15:24:33 +08:00
Nexmoe
12ec142a77 ci(release): notify Cloudflare Pages (#78) 2026-01-11 15:21:40 +08:00
Nexmoe
0136da410d chore: release v1.1.6 2026-01-11 10:21:42 +08:00
Nexmoe
4971a5fe8a feat(settings): improve cookie profile handling (#74)
* feat(settings): improve cookie profile handling

* fix(settings): normalize profile input
2026-01-11 10:16:28 +08:00
Nexmoe
d2806164a2 feat(extension): integrate local video info API (#67)
* feat(extension): integrate local video info API

* chore(repo): clean up popup styles

* feat(extension): revamp popup UI

* chore(repo): revert docs and drop content script
2026-01-11 00:05:46 +08:00
Nexmoe
e332ec4ddc chore: release v1.1.5 2026-01-10 19:18:55 +08:00
Nexmoe
3b74712f57 fix(subscription): improve cover image selection (#73)
* fix(subscriptions): improve rss cover fallback

* fix(subscriptions): match img src first
2026-01-10 19:18:03 +08:00
Nexmoe
475e4127c2 fix(remote-image): add load timeout (#72) 2026-01-10 18:39:00 +08:00
Nexmoe
3041307aa2 fix: Advanced settings tab broken (#71) 2026-01-10 18:30:18 +08:00
Nexmoe
e2ab8dce60 chore: release v1.1.4 2026-01-09 21:39:01 +08:00
Nexmoe
c1806e5321 fix(main): hide window on autostart (#68) 2026-01-09 21:03:25 +08:00
Nexmoe
92494966c6 fix(settings): merge defaults for getAll (#66) 2026-01-09 20:53:08 +08:00
Nexmoe
f04680b8c2 chore: release v1.1.3 2026-01-02 16:17:42 +08:00
Nexmoe
a5b94411fe fix(download): handle empty format selections (#64) 2026-01-02 16:16:04 +08:00
Nexmoe
71ae4a4425 fix(download): add format fallback selectors (#63) 2026-01-01 14:41:59 +08:00
Nexmoe
e8413aefae feat(ui): show update indicator on About (#58)
* feat(ui): add update indicator for about

* fix(ci): remove unused import

* fix(ci): retry binary downloads
2025-12-26 16:46:55 +08:00
Nexmoe
b229225b6e chore: release v1.1.2 2025-12-26 13:04:52 +08:00
Nexmoe
5497ed6245 chore(templates): simplify bug report (#57) 2025-12-26 13:04:30 +08:00
Nexmoe
155bf652f2 fix(ci): harden download steps (#56) 2025-12-26 13:02:21 +08:00
Nexmoe
86bd77d995 fix(download): improve format fallbacks (#55) 2025-12-26 13:02:07 +08:00
Nexmoe
b38b356c22 feat(issue): add issue templates (#54) 2025-12-26 12:53:51 +08:00
Nexmoe
5020605590 chore: release v1.1.1 2025-12-25 22:20:18 +08:00
Nexmoe
b15c3d8ce5 feat(about): refresh description and links (#52) 2025-12-25 22:18:04 +08:00
Nexmoe
a51655697d fix(update): streamline update notification (#51) 2025-12-23 09:43:14 +08:00
Nexmoe
f5c26f5f02 Animate advanced options panels (#47) 2025-12-21 13:32:46 +08:00
Nexmoe
3bc9d76f6e Add JS runtime support for yt-dlp #36 (#46)
* Add yt-dlp JS runtime support

* Update build scripts and logging

* fix: update README to include DeepWiki badge and correct contributor badge logo
2025-12-21 10:38:04 +08:00
Nexmoe
5751d73d5b Limit electron languages to English (#45) 2025-12-20 22:34:06 +08:00
Nexmoe
50bd9f1659 fix: update README download links to point to new VidBee website
* Replace individual OS download links with a single link to the VidBee download page for improved clarity and accessibility.
2025-12-20 21:49:46 +08:00
115 changed files with 13650 additions and 3690 deletions

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View 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

View File

@@ -8,6 +8,17 @@ on:
type: boolean
default: false
description: 'Whether to upload build artifacts'
secrets:
MAC_CERT_P12_BASE64:
required: false
MAC_CERT_P12_PASSWORD:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_API_KEY_P8_BASE64:
required: false
jobs:
build:
@@ -22,16 +33,16 @@ jobs:
ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
ffmpeg_output: ffmpeg.exe
ffprobe_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip
ffmpeg_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos
ffprobe_inner_path: ffmpeg/ffprobe
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
@@ -39,7 +50,7 @@ jobs:
ytdlp_output: yt-dlp_linux
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux
ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe
steps:
- name: Check out Git repository
uses: actions/checkout@v4
@@ -66,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

View File

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

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

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

View File

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

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules
/dist
out
.conductor/
.wxt
.output
.DS_Store
.eslintcache
*.log*

View File

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

View File

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

View File

@@ -5,11 +5,12 @@
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&style=flat-square&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<br />
<br />
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
@@ -27,11 +28,7 @@ VidBee is currently under active development, and feedback is welcome for any [i
Feel free to try it using the following methods:
| Operating System | Source |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
<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

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

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

View File

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

View File

@@ -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"
}
}

View File

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

29
extension/.gitignore vendored Normal file
View 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
View 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.

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

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

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

View 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

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

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

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

7
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}

13
extension/wxt.config.ts Normal file
View 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']
}
})

View File

@@ -1,12 +1,13 @@
{
"name": "vidbee",
"version": "1.1.0",
"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",
@@ -15,10 +16,10 @@
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
"build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "git checkout main && git pull && pnpm run check && bumpp",
"db:generate": "drizzle-kit generate"
},
@@ -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
View File

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

View File

@@ -6,6 +6,11 @@ ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
ffmpeg/
ffprobe
ffprobe.exe
deno.exe
deno
# But keep the README
!README.md

View File

@@ -48,24 +48,44 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
```
## ffmpeg Binaries
## ffmpeg/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)
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
### Required Files
1. **Windows**: `deno.exe`
2. **macOS**: `deno`
3. **Linux**: `deno`
### How to Download
- Visit: <https://github.com/denoland/deno/releases/latest>
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
### Note
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.

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

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

View File

@@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) {
const binaries = [
{
label: 'yt-dlp',
filenameMap: {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
paths: {
win: ['yt-dlp.exe'],
mac: ['yt-dlp_macos'],
linux: ['yt-dlp_linux']
},
help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
@@ -34,28 +34,55 @@ 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',
linux: 'https://ffmpeg.org/download.html',
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
}
},
{
label: 'deno',
paths: {
win: ['deno.exe'],
mac: ['deno'],
linux: ['deno']
},
help: {
default: 'https://github.com/denoland/deno/releases/latest'
}
}
]
let hasMissingBinary = false
for (const binary of binaries) {
const filename = binary.filenameMap[platform]
const binaryPath = path.join(__dirname, '..', 'resources', filename)
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) {
@@ -63,7 +90,7 @@ for (const binary of binaries) {
}
hasMissingBinary = true
} else {
console.log(`${filename} found in resources/ directory`)
console.log(`${binary.label} found: resources/${found}`)
}
}

View File

@@ -8,13 +8,15 @@
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 =
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
@@ -28,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'
}
}
@@ -45,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',
@@ -55,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',
@@ -74,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'
}
}
@@ -103,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
@@ -257,12 +328,79 @@ 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') {
return 'deno-aarch64-pc-windows-msvc.zip'
}
return 'deno-x86_64-pc-windows-msvc.zip'
}
if (platform === 'darwin') {
if (arch === 'arm64') {
return 'deno-aarch64-apple-darwin.zip'
}
return 'deno-x86_64-apple-darwin.zip'
}
if (platform === 'linux') {
if (arch === 'arm64') {
return 'deno-aarch64-unknown-linux-gnu.zip'
}
return 'deno-x86_64-unknown-linux-gnu.zip'
}
return null
}
function getDenoOutputName(platform) {
return platform === 'win32' ? 'deno.exe' : 'deno'
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
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
}
@@ -272,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)) {
@@ -285,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 {
@@ -308,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')
@@ -315,7 +486,7 @@ async function downloadFfmpegWindows(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -325,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)
@@ -345,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
@@ -370,7 +581,7 @@ async function downloadFfmpegMac(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -381,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
@@ -394,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 {
@@ -417,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')
@@ -424,7 +676,7 @@ async function downloadFfmpegLinux(config) {
}
try {
await downloadFile(downloadUrl, tempTar)
await downloadFileWithRetry(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
@@ -435,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
@@ -447,6 +712,61 @@ async function downloadFfmpegLinux(config) {
}
}
async function downloadDenoRuntime() {
const platform = os.platform()
const arch = os.arch()
const assetName = getDenoAssetName(platform, arch)
if (!assetName) {
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
return
}
const outputName = getDenoOutputName(platform)
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
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
}
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting Deno runtime...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, outputName)
if (!fileExists(sourcePath)) {
throw new Error(`Deno binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
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)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
@@ -464,6 +784,9 @@ async function setup() {
// Download yt-dlp
await downloadYtDlp(config)
// Download JS runtime (Deno)
await downloadDenoRuntime()
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)

View File

@@ -5,15 +5,6 @@ import log from 'electron-log/main'
* Set log format, file path, transport methods, etc.
*/
export function configureLogger() {
// Configure console output format - support colors and scope, time in gray
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
// Enable console colors
log.transports.console.useStyles = true
// Configure file output format - include scope information
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
// Set log levels
// Development: show all logs
// Production: show info level and above only

View File

@@ -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,12 +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
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')
@@ -72,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
@@ -90,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
@@ -104,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') {
@@ -130,6 +176,10 @@ export const buildDownloadArgs = (
args.push('--config-location', configPath)
}
if (jsRuntimeArgs.length > 0) {
args.push(...jsRuntimeArgs)
}
args.push(options.url)
return args

View File

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

View File

@@ -2,7 +2,14 @@ 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'
@@ -13,6 +20,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'
@@ -129,6 +137,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 +177,9 @@ export function createWindow(): void {
})
mainWindow.on('ready-to-show', () => {
if (shouldStartHidden) {
return
}
mainWindow?.show()
})
@@ -190,10 +202,48 @@ 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-started', (id: string) => {
mainWindow?.webContents.send('download:started', id)
@@ -314,14 +364,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', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
log.info('Auto-updater initialized successfully')
@@ -381,6 +423,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
@@ -396,14 +449,22 @@ app.whenReady().then(async () => {
}
// Initialize yt-dlp
let ytdlpReady = false
try {
log.info('Initializing yt-dlp...')
await ytdlpManager.initialize()
ytdlpReady = true
log.info('yt-dlp initialized successfully')
} catch (error) {
log.error('Failed to initialize yt-dlp:', error)
}
if (ytdlpReady) {
downloadEngine.restoreActiveDownloads()
}
await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
@@ -419,14 +480,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
@@ -451,6 +525,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

View File

@@ -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,

View File

@@ -1,6 +1,7 @@
import os from 'node:os'
import { app, BrowserWindow, dialog } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
class AppService extends IpcService {
static readonly groupName = 'app'
@@ -15,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()
@@ -48,7 +80,7 @@ class AppService extends IpcService {
const base64 = buffer.toString('base64')
return `data:${contentType};base64,${base64}`
} catch (error) {
console.error('Failed to fetch site icon:', error)
scopedLoggers.system.error('Failed to fetch site icon:', error)
return null
}
}

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

View File

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

View File

@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { clipboard, dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
const execFileAsync = promisify(execFile)
@@ -49,7 +50,10 @@ class FileSystemService extends IpcService {
return xdgPath
}
} catch (error) {
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
scopedLoggers.system.warn(
'Unable to resolve XDG download directory, falling back to default:',
error
)
}
}
@@ -75,7 +79,7 @@ class FileSystemService extends IpcService {
if (stats?.isDirectory()) {
const result = await shell.openPath(normalizedPath)
if (result) {
console.error('Failed to open directory:', result)
scopedLoggers.system.error('Failed to open directory:', result)
return false
}
return true
@@ -88,16 +92,16 @@ class FileSystemService extends IpcService {
if (parentStats?.isDirectory()) {
const result = await shell.openPath(parentDirectory)
if (result) {
console.error('Failed to open parent directory:', result)
scopedLoggers.system.error('Failed to open parent directory:', result)
return false
}
return true
}
console.error('File or directory does not exist:', normalizedPath)
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
return false
} catch (error) {
console.error('Failed to open file location:', error)
scopedLoggers.system.error('Failed to open file location:', error)
return false
}
}
@@ -122,7 +126,7 @@ class FileSystemService extends IpcService {
return true
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
return false
}
}
@@ -150,7 +154,7 @@ class FileSystemService extends IpcService {
await shell.openExternal(url)
return true
} catch (error) {
console.error('Failed to open external URL:', error)
scopedLoggers.system.error('Failed to open external URL:', error)
return false
}
}
@@ -166,7 +170,10 @@ class FileSystemService extends IpcService {
])
return
} catch (error) {
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'PowerShell clipboard copy failed, falling back to manual buffer:',
error
)
}
const winPath = resolvedPath.replace(/\//g, '\\')
@@ -194,7 +201,10 @@ class FileSystemService extends IpcService {
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
return
} catch (error) {
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'osascript clipboard copy failed, falling back to manual buffer:',
error
)
}
const entries = [
@@ -243,7 +253,7 @@ class FileSystemService extends IpcService {
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
scopedLoggers.system.error('Failed to check file existence:', error)
return false
}
}
@@ -295,7 +305,7 @@ class FileSystemService extends IpcService {
return false
} catch (error) {
console.error('Failed to delete file:', error)
scopedLoggers.system.error('Failed to delete file:', error)
return false
}
}

View File

@@ -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()
}
}

View File

@@ -6,7 +6,10 @@ import type {
SubscriptionRule,
SubscriptionUpdatePayload
} from '../../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
import {
DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE,
SUBSCRIPTION_DUPLICATE_FEED_ERROR
} from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
@@ -113,6 +116,10 @@ class SubscriptionService extends IpcService {
options: CreateSubscriptionOptions
): Promise<SubscriptionRule> {
const resolved = resolveFeedFromInput(options.url)
const duplicate = subscriptionManager.findDuplicateFeed(resolved.feedUrl)
if (duplicate) {
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
}
const settings = settingsManager.getAll()
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
const payload: SubscriptionCreatePayload = {
@@ -141,6 +148,12 @@ class SubscriptionService extends IpcService {
id: string,
updates: SubscriptionUpdatePayload
): SubscriptionRule | undefined {
if (updates.feedUrl) {
const duplicate = subscriptionManager.findDuplicateFeed(updates.feedUrl, id)
if (duplicate) {
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
}
}
const normalized: SubscriptionUpdatePayload = { ...updates }
if (typeof normalized.namingTemplate === 'string') {
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)

View File

@@ -15,6 +15,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'),

View File

@@ -11,7 +11,8 @@ import type {
PlaylistDownloadResult,
PlaylistInfo,
VideoFormat,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../shared/types'
import {
buildDownloadArgs,
@@ -27,6 +28,11 @@ import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger'
import { resolvePathWithHome } from '../utils/path-helpers'
import { DownloadQueue } from './download-queue'
import {
type DownloadSessionItem,
loadDownloadSession,
saveDownloadSession
} from './download-session-store'
import { ffmpegManager } from './ffmpeg-manager'
import { historyManager } from './history-manager'
import { ytdlpManager } from './ytdlp-manager'
@@ -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,
@@ -145,9 +219,56 @@ const resolveHistoryDownloadPath = (
return path.join(basePath, templateDir)
}
const appendJsRuntimeArgs = (args: string[]): void => {
const runtimeArgs = ytdlpManager.getJsRuntimeArgs()
if (runtimeArgs.length > 0) {
args.push(...runtimeArgs)
}
}
const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsManager.getAll>) => {
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
return args
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue
private sessionPersistTimer: NodeJS.Timeout | null = null
private sessionRestored = false
constructor() {
super()
@@ -157,44 +278,17 @@ class DownloadEngine extends EventEmitter {
this.queue.on('start-download', async (item) => {
await this.executeDownload(item.id, item.options)
})
this.queue.on('queue-updated', () => {
this.scheduleSessionPersist()
})
}
async getVideoInfo(url: string): Promise<VideoInfo> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
args.push(url)
const args = buildVideoInfoArgs(url, settings)
return new Promise((resolve, reject) => {
const process = ytdlp.exec(args)
@@ -260,6 +354,90 @@ class DownloadEngine extends EventEmitter {
})
}
async getVideoInfoWithCommand(url: string): Promise<VideoInfoCommandResult> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = buildVideoInfoArgs(url, settings)
const ytDlpCommand = formatYtDlpCommand(args)
return new Promise((resolve) => {
let settled = false
const resolveOnce = (payload: VideoInfoCommandResult) => {
if (settled) {
return
}
settled = true
resolve(payload)
}
const process = ytdlp.exec(args)
let stdout = ''
let stderr = ''
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
stdout += data.toString()
})
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
})
process.on('close', (code) => {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
// Calculate estimated file size for formats missing filesize information
// Using tbr (total bitrate in kbps) and duration (in seconds)
// Formula: (tbr * 1000) / 8 * duration = size in bytes
if (info.formats && Array.isArray(info.formats) && info.duration) {
const duration = info.duration
for (const format of info.formats) {
if (
!format.filesize &&
!format.filesize_approx &&
format.tbr &&
typeof format.tbr === 'number' &&
duration > 0
) {
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
format.filesize_approx = estimatedSize
}
}
}
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolveOnce({ info, ytDlpCommand })
} catch (error) {
scopedLoggers.download.error('Failed to parse video info for:', url, error)
resolveOnce({
ytDlpCommand,
error: `Failed to parse video info: ${error instanceof Error ? error.message : error}`
})
}
} else {
scopedLoggers.download.error(
'Failed to fetch video info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' })
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error for:', url, error)
resolveOnce({
ytDlpCommand,
error: error instanceof Error ? error.message : 'Failed to fetch video info'
})
})
})
}
async getPlaylistInfo(url: string): Promise<PlaylistInfo> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
@@ -290,6 +468,7 @@ class DownloadEngine extends EventEmitter {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
type RawPlaylistEntry = {
@@ -511,7 +690,7 @@ class DownloadEngine extends EventEmitter {
startDownload(id: string, options: DownloadOptions): void {
if (this.activeDownloads.has(id)) {
console.warn(`Download ${id} is already active`)
scopedLoggers.engine.warn(`Download ${id} is already active`)
return
}
@@ -569,6 +748,9 @@ 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 {
@@ -582,6 +764,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,
@@ -644,7 +834,12 @@ class DownloadEngine extends EventEmitter {
return true
}
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
const args = buildDownloadArgs(
options,
resolvedDownloadPath,
settings,
ytdlpManager.getJsRuntimeArgs()
)
const captureOutputPath = (rawPath: string | undefined): void => {
if (!rawPath) {
@@ -715,9 +910,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
@@ -725,6 +925,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, {
@@ -756,13 +958,33 @@ class DownloadEngine extends EventEmitter {
: downloadedBytes
}
const normalizedPercent = clampPercent(progress.percent)
if (
totalParts > 1 &&
lastPercent >= 90 &&
normalizedPercent <= 10 &&
completedParts < totalParts - 1
) {
completedParts += 1
}
lastPercent = normalizedPercent
const mergedPercent =
totalParts > 1
? ((completedParts + normalizedPercent / 100) / totalParts) * 100
: normalizedPercent
const downloadProgress: DownloadProgress = {
percent: progress.percent || 0,
percent: Math.min(100, mergedPercent),
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
}
this.queue.updateItemInfo(id, {
progress: downloadProgress,
speed: downloadProgress.currentSpeed || ''
})
this.scheduleSessionPersist()
this.emit('download-progress', id, downloadProgress)
}
)
@@ -815,7 +1037,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)
@@ -976,6 +1199,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)
@@ -1037,6 +1326,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
}
@@ -1044,6 +1336,37 @@ class DownloadEngine extends EventEmitter {
if (Object.keys(historyUpdates).length > 0) {
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
}
this.scheduleSessionPersist()
}
private scheduleSessionPersist(): void {
if (this.sessionPersistTimer) {
return
}
this.sessionPersistTimer = setTimeout(() => {
this.sessionPersistTimer = null
this.persistSession()
}, 1000)
}
private persistSession(): void {
const entries: DownloadSessionItem[] = []
const activeEntries = this.queue.getActiveEntries()
const queuedEntries = this.queue.getQueuedEntries()
for (const entry of [...activeEntries, ...queuedEntries]) {
if (!entry?.item?.id) {
continue
}
entries.push({
id: entry.item.id,
options: entry.options,
item: entry.item
})
}
saveDownloadSession(entries)
}
private addToHistory(
@@ -1101,6 +1424,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,

View File

@@ -82,6 +82,28 @@ export class DownloadQueue extends EventEmitter {
}
}
getActiveItems(): DownloadItem[] {
return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item }))
}
getQueuedItems(): DownloadItem[] {
return this.queue.map((item) => ({ ...item.item }))
}
getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
return Array.from(this.activeDownloads.values()).map((entry) => ({
options: { ...entry.options },
item: { ...entry.item }
}))
}
getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
return this.queue.map((entry) => ({
options: { ...entry.options },
item: { ...entry.item }
}))
}
isDownloading(id: string): boolean {
return this.activeDownloads.has(id)
}

View File

@@ -0,0 +1,67 @@
import fs from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
import type { DownloadItem, DownloadOptions } from '../../shared/types'
import { scopedLoggers } from '../utils/logger'
export interface DownloadSessionItem {
id: string
options: DownloadOptions
item: DownloadItem
}
interface DownloadSessionPayload {
version: 1
updatedAt: number
items: DownloadSessionItem[]
}
const SESSION_FILE_NAME = 'download-session.json'
const getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME)
const isValidItem = (item: DownloadSessionItem): boolean =>
Boolean(item?.id && item.options && item.item)
export const loadDownloadSession = (): DownloadSessionItem[] => {
const filePath = getSessionFilePath()
if (!fs.existsSync(filePath)) {
return []
}
try {
const raw = fs.readFileSync(filePath, 'utf-8')
const payload = JSON.parse(raw) as DownloadSessionPayload
if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) {
return []
}
return payload.items.filter(isValidItem)
} catch (error) {
scopedLoggers.download.warn('Failed to load download session:', error)
return []
}
}
export const saveDownloadSession = (items: DownloadSessionItem[]): void => {
const filePath = getSessionFilePath()
if (items.length === 0) {
try {
fs.rmSync(filePath, { force: true })
} catch (error) {
scopedLoggers.download.warn('Failed to clear download session:', error)
}
return
}
const payload: DownloadSessionPayload = {
version: 1,
updatedAt: Date.now(),
items
}
try {
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8')
} catch (error) {
scopedLoggers.download.warn('Failed to save download session:', error)
}
}

View File

@@ -2,13 +2,14 @@ import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { scopedLoggers } from '../utils/logger'
class FfmpegManager {
private ffmpegPath: string | null = null
async initialize(): Promise<void> {
this.ffmpegPath = await this.findFfmpegBinary()
console.log('ffmpeg initialized at:', this.ffmpegPath)
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
}
getPath(): string {
@@ -27,43 +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)) {
console.log('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) {
console.warn('Failed to set executable permission on ffmpeg binary:', error)
}
}
console.log('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)) {
console.log('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
}
}
}
@@ -72,8 +88,10 @@ class FfmpegManager {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system ffmpeg:', systemPath)
return systemPath
const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')
if (resolved) {
return resolved
}
}
} catch (_error) {
// Ignore error and continue
@@ -84,8 +102,10 @@ class FfmpegManager {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
console.log('Using system ffmpeg:', output)
return output
const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')
if (resolved) {
return resolved
}
}
} catch (_error) {
// Ignore error and continue
@@ -93,7 +113,7 @@ class FfmpegManager {
}
throw new Error(
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
'ffmpeg/ffprobe not found. Bundle them under resources/ffmpeg/ (asarUnpack) or set FFMPEG_PATH to a directory containing both.'
)
}
}

View File

@@ -36,6 +36,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,

View File

@@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter {
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
}
findDuplicateFeed(
feedUrl: string,
ignoreId?: string
): { id: string; feedUrl: string } | undefined {
const database = this.getDatabase()
const rows = database
.select({ id: subscriptionsTable.id, feedUrl: subscriptionsTable.feedUrl })
.from(subscriptionsTable)
.all()
const targetKey = this.buildFeedKey(feedUrl)
if (!targetKey) {
return undefined
}
return rows.find((row) => row.id !== ignoreId && this.buildFeedKey(row.feedUrl) === targetKey)
}
add(payload: SubscriptionCreatePayload): SubscriptionRule {
const timestamp = Date.now()
const keywords = sanitizeList(payload.keywords)
@@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter {
return this.db
}
private buildFeedKey(feedUrl: string): string {
const trimmed = feedUrl.trim()
if (!trimmed) {
return ''
}
const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try {
const url = new URL(normalized)
let pathname = url.pathname || '/'
pathname = pathname.replace(/\/+$/, '')
if (!pathname) {
pathname = '/'
}
return `${url.host.toLowerCase()}${pathname}${url.search}`
} catch {
return trimmed.toLowerCase()
}
}
private ensureItemsSchema(): void {
if (!this.sqlite) {
return

View File

@@ -4,6 +4,10 @@ import log from 'electron-log/main'
import Parser from 'rss-parser'
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
import {
buildAudioFormatPreference,
buildVideoFormatPreference
} from '../../shared/utils/format-preferences'
import { settingsManager } from '../settings'
import { downloadEngine } from './download-engine'
import { historyManager } from './history-manager'
@@ -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,

View File

@@ -4,6 +4,7 @@ import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app } from 'electron'
import { scopedLoggers } from '../utils/logger'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
@@ -81,7 +82,7 @@ export class ThumbnailCache {
await fsPromises.writeFile(finalPath, buffer)
return this.toAppProtocolUrl(finalPath)
} catch (error) {
console.error('Failed to cache thumbnail:', error)
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
return null
}
}

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type YTDlpWrap from 'yt-dlp-wrap-plus'
import { scopedLoggers } from '../utils/logger'
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
class YtDlpManager {
private ytdlpPath: string | null = null
private ytdlpInstance: YTDlpWrapInstance | null = null
private jsRuntimeArgs: string[] = []
async initialize(): Promise<void> {
this.ytdlpPath = await this.findOrDownloadYtDlp()
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
console.log('yt-dlp initialized at:', this.ytdlpPath)
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
}
getInstance(): YTDlpWrapInstance {
@@ -33,6 +36,10 @@ class YtDlpManager {
return this.ytdlpPath
}
getJsRuntimeArgs(): string[] {
return [...this.jsRuntimeArgs]
}
private getResourcesPath(): string {
// In development, read from project root's resources
if (process.env.NODE_ENV === 'development') {
@@ -57,7 +64,7 @@ class YtDlpManager {
// Check environment variable first
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
return process.env.YTDLP_PATH
}
@@ -65,13 +72,13 @@ class YtDlpManager {
const resourcesPath = this.getResourcesPath()
const bundledPath = path.join(resourcesPath, bundledName)
if (fs.existsSync(bundledPath)) {
console.log('Using bundled yt-dlp:', bundledPath)
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
// Make executable on Unix-like systems if needed
if (platform !== 'win32') {
try {
fs.chmodSync(bundledPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission:', error)
scopedLoggers.engine.warn('Failed to set executable permission:', error)
}
}
return bundledPath
@@ -82,7 +89,7 @@ class YtDlpManager {
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
console.log('Using system yt-dlp:', p)
scopedLoggers.engine.info('Using system yt-dlp:', p)
return p
}
}
@@ -93,7 +100,7 @@ class YtDlpManager {
try {
const systemPath = execSync('which yt-dlp').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system yt-dlp:', systemPath)
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
return systemPath
}
} catch (_error) {
@@ -115,6 +122,92 @@ class YtDlpManager {
)
}
private resolveJsRuntimeArgs(): string[] {
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
if (!runtime || runtime === 'none') {
return []
}
const runtimePath = this.resolveJsRuntimePath(runtime)
if (runtimePath) {
return ['--js-runtimes', `${runtime}:${runtimePath}`]
}
if (process.env.YTDLP_JS_RUNTIME) {
scopedLoggers.engine.warn(
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
)
} else {
scopedLoggers.engine.warn(
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
)
}
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
}
private resolveJsRuntimePath(runtime: string): string | null {
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
if (envPath && fs.existsSync(envPath)) {
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
return envPath
}
const platform = os.platform()
const resourcesPath = this.getResourcesPath()
const resourceCandidates: string[] = []
if (runtime === 'deno') {
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
} else if (runtime === 'node') {
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
} else if (runtime === 'bun') {
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
} else if (runtime === 'quickjs') {
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
} else {
resourceCandidates.push(runtime)
if (platform === 'win32' && !runtime.endsWith('.exe')) {
resourceCandidates.push(`${runtime}.exe`)
}
}
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
}
}
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
return fullPath
}
}
try {
if (platform === 'win32') {
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system JS runtime:', output)
return output
}
} else {
const systemPath = execSync(`which ${runtime}`).toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
return systemPath
}
}
} catch (_error) {
// Runtime not found in PATH
}
return null
}
// Removed runtime download/update to avoid network dependency in production builds
}

191
src/main/local-api.ts Normal file
View 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()
}

View File

@@ -3,6 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import type { AppSettings } from '../shared/types'
import { defaultSettings } from '../shared/types'
import { scopedLoggers } from './utils/logger'
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
@@ -14,14 +15,12 @@ const ensureDirectoryExists = (dir: string) => {
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
console.error('Failed to ensure download directory:', error)
scopedLoggers.system.error('Failed to ensure download directory:', error)
}
}
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()
@@ -52,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 {
@@ -70,7 +73,6 @@ class SettingsManager {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH
})
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
}
private ensureDownloadDirectory(): void {
@@ -83,9 +85,8 @@ class SettingsManager {
if (normalizedDownloadPath !== currentPath) {
this.store.set('downloadPath', normalizedDownloadPath)
}
ensureDirectoryExists(normalizedDownloadPath)
} catch (error) {
console.error('Failed to verify download directory:', error)
scopedLoggers.system.error('Failed to verify download directory:', error)
}
}
}

View File

@@ -5,7 +5,7 @@ declare global {
interface Window {
electron: ElectronAPI
api: IpcServices & {
on: (channel: string, callback: (...args: unknown[]) => void) => void
on: (channel: string, callback: (...args: unknown[]) => void) => (...args: unknown[]) => void
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
send: (channel: string, ...args: unknown[]) => void
}

View File

@@ -1,4 +1,3 @@
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
@@ -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] ?? '/'
@@ -168,10 +174,39 @@ function AppContent() {
return
}
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
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: t('about.notifications.restartNowAction'),
label: i18n.t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
@@ -179,31 +214,12 @@ function AppContent() {
})
}
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState()
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.success(downloadedMessage)
showRestartPrompt()
}
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) => {
@@ -213,37 +229,20 @@ function AppContent() {
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
const versionLabel = payload.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? 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">
@@ -259,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} />
@@ -290,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>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
import {
DOWNLOAD_FEEDBACK_ISSUE_TITLE,
FeedbackLinkButtons
} from '@renderer/components/feedback/FeedbackLinks'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
@@ -149,7 +153,7 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
if (!format) {
return undefined
}
if (download.type === 'audio' || download.type === 'extract') {
if (download.type === 'audio') {
return sanitizeCodec(format.acodec)
}
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
@@ -400,6 +404,10 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
const statusIcon = getStatusIcon()
const statusText = getStatusText()
const progressInfo = download.progress
const showInlineProgress = Boolean(
progressInfo && download.status !== 'completed' && download.status !== 'error'
)
const sourceDisplay =
download.uploader && download.channel && download.uploader !== download.channel
? `${download.uploader}${download.channel}`
@@ -647,15 +655,12 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
return (
<div
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
className={`px-6 py-2 group relative w-full max-w-full overflow-hidden transition-colors ${
isSelectedHistory ? 'bg-primary/10' : ''
}`}
>
{isSelectedHistory && (
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
)}
<div
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
className={`flex w-full flex-col gap-2 sm:flex-row sm:gap-3 ${
selectionEnabled ? 'cursor-pointer' : ''
}`}
{...(selectionEnabled
@@ -674,7 +679,7 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
: {})}
>
{/* Thumbnail */}
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 h-14 aspect-video pointer-events-none">
{selectionEnabled && (
<div
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
@@ -700,13 +705,18 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
</div>
{/* Content */}
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
<div className="flex-1 min-w-0 max-w-full overflow-hidden pointer-events-none">
<div className="flex items-center justify-center h-14 w-full flex-col gap-1.5 sm:flex-row sm:justify-between sm:gap-2">
<div className="flex-1 items-center min-w-0 max-w-full space-y-1.5 overflow-hidden">
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
{download.title}
</p>
{download.type === 'audio' && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('download.audio')}
</Badge>
)}
{isSubscriptionDownload && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('subscriptions.labels.subscription')}
@@ -725,6 +735,24 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
</TooltipContent>
</Tooltip>
)}
{showInlineProgress && (
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium shrink-0">
{(progressInfo?.percent ?? 0).toFixed(1)}%
</span>
{progressInfo?.downloaded && progressInfo?.total && (
<span className="truncate max-w-[120px]">
{progressInfo.downloaded} / {progressInfo.total}
</span>
)}
{progressInfo?.currentSpeed && (
<span className="truncate max-w-[80px]">{progressInfo.currentSpeed}</span>
)}
{progressInfo?.eta && (
<span className="truncate max-w-[80px]">ETA: {progressInfo.eta}</span>
)}
</div>
)}
{/* Timestamp */}
{timestamp && (
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
@@ -898,34 +926,37 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
{/* Progress */}
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
<div className="bg-background/60 w-full overflow-hidden">
<Progress value={download.progress.percent} className="h-1 w-full" />
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
<span className="font-medium shrink-0">
{download.progress.percent.toFixed(1)}%
</span>
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total}
</span>
)}
{download.progress.currentSpeed && (
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
)}
{download.progress.eta && (
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
)}
</div>
</div>
</div>
)}
{/* Error message */}
{download.status === 'error' && download.error && (
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
<div className="flex flex-col gap-2">
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground pointer-events-auto">
<span className="text-xs font-medium text-muted-foreground">
{t('download.feedback.title')}
</span>
<div className="flex flex-wrap gap-2">
<FeedbackLinkButtons
error={download.error}
sourceUrl={download.url}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={download.ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-6 gap-1 px-1.5 text-[10px]"
iconClassName="h-3 w-3"
onLinkClick={(event) => event.stopPropagation()}
/>
</div>
</div>
</div>
)}
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import {
removeHistoryRecordsByPlaylistAtom
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
import { ScrollArea } from '../ui/scroll-area'
import { DownloadDialog } from './DownloadDialog'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
@@ -112,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
return download.type === 'audio' ? 'mp3' : 'mp4'
}
const isEditableTarget = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof HTMLElement)) {
return false
}
if (target.isContentEditable) {
return true
}
const tagName = target.tagName
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
}
interface UnifiedDownloadHistoryProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
@@ -162,6 +174,12 @@ export function UnifiedDownloadHistory({
})
}, [allRecords, statusFilter])
const visibleHistoryIds = useMemo(
() =>
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
[filteredRecords]
)
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
{ key: 'all', label: t('download.all'), count: downloadStats.total },
{ key: 'active', label: t('download.active'), count: downloadStats.active },
@@ -169,16 +187,34 @@ export function UnifiedDownloadHistory({
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const selectableIds = useMemo(
() =>
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
[filteredRecords]
)
const selectableIds = useMemo(() => {
if (visibleHistoryIds.length === 0) {
return []
}
const ids = new Set(visibleHistoryIds)
const playlistIds = new Set(
filteredRecords
.filter((record) => record.entryType === 'history' && record.playlistId)
.map((record) => record.playlistId as string)
)
if (playlistIds.size === 0) {
return Array.from(ids)
}
for (const record of historyRecords) {
if (record.playlistId && playlistIds.has(record.playlistId)) {
ids.add(record.id)
}
}
return Array.from(ids)
}, [filteredRecords, historyRecords, visibleHistoryIds])
const selectableCount = selectableIds.length
const visibleSelectableCount = visibleHistoryIds.length
const selectionSummary =
selectableCount === 0
? t('history.selectedCount', { count: selectedCount })
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
: selectableCount > visibleSelectableCount
? t('history.selectedCount', { count: selectedCount })
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
useEffect(() => {
if (selectedIds.size === 0) {
@@ -215,6 +251,13 @@ export function UnifiedDownloadHistory({
setSelectedIds(new Set())
}
const handleSelectAll = () => {
if (selectableIds.length === 0) {
return
}
setSelectedIds(new Set(selectableIds))
}
const handleRequestDeleteSelected = () => {
if (selectedIds.size === 0) {
return
@@ -397,9 +440,44 @@ export function UnifiedDownloadHistory({
return { order, groups }
}, [filteredRecords])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) {
return
}
if (isEditableTarget(event.target)) {
return
}
if (event.key === 'Escape') {
if (confirmAction) {
return
}
if (selectedIds.size === 0) {
return
}
setSelectedIds(new Set())
return
}
if (!(event.metaKey || event.ctrlKey)) {
return
}
if (event.key.toLowerCase() !== 'a') {
return
}
if (selectableIds.length === 0) {
return
}
event.preventDefault()
setSelectedIds(new Set(selectableIds))
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [confirmAction, selectableIds, selectedIds])
return (
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
<div className={cn('flex flex-col h-full')}>
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
{filters.map((filter) => {
@@ -430,6 +508,15 @@ export function UnifiedDownloadHistory({
})}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 rounded-full px-3"
onClick={handleSelectAll}
disabled={selectableIds.length === 0}
>
{t('history.selectAll')}
</Button>
<DownloadDialog
onOpenSupportedSites={onOpenSupportedSites}
onOpenSettings={onOpenSettings}
@@ -437,47 +524,49 @@ export function UnifiedDownloadHistory({
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-4 w-full">
{groupedView.order.map((item) => {
if (item.type === 'single') {
<ScrollArea className="overflow-y-auto flex-1">
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="w-full pb-4">
{groupedView.order.map((item) => {
if (item.type === 'single') {
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
isSelected={selectedIds.has(item.record.id)}
onToggleSelect={handleToggleSelect}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
isSelected={selectedIds.has(item.record.id)}
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onDeletePlaylist={handleRequestDeletePlaylist}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onDeletePlaylist={handleRequestDeletePlaylist}
/>
)
})}
</div>
)}
</CardContent>
})}
</div>
)}
</CardContent>
</ScrollArea>
{selectedCount > 0 && (
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">

View File

@@ -0,0 +1,152 @@
import { ipcServices } from '@renderer/lib/ipc'
import { logger } from '@renderer/lib/logger'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage'
interface Props {
children: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
fallback?: (errorInfo: ErrorInfoType) => ReactNode
}
interface State {
hasError: boolean
errorInfo: ErrorInfoType | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
errorInfo: null
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
const errorInfo = {
error,
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform
}
}
// Log error details immediately
logger.error('ErrorBoundary: getDerivedStateFromError called', {
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
url: errorInfo.context.url,
timestamp: errorInfo.timestamp
})
return {
hasError: true,
errorInfo
}
}
async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise<void> {
logger.error('ErrorBoundary caught an error:', {
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
componentStack: errorInfo.componentStack,
errorInfo: JSON.stringify(errorInfo, null, 2)
})
// Get app version if available
let appVersion: string | undefined
try {
if (window?.api && ipcServices?.app) {
appVersion = await ipcServices.app.getVersion()
logger.info('ErrorBoundary: App version retrieved', { appVersion })
}
} catch (err) {
logger.warn('Failed to get app version:', err)
}
// Update state with component stack and version
if (this.state.errorInfo) {
this.setState({
errorInfo: {
...this.state.errorInfo,
context: {
...this.state.errorInfo.context,
version: appVersion
},
errorInfo: {
componentStack: errorInfo.componentStack || undefined
}
}
})
}
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
// Send error to main process if available
if (window?.api) {
try {
window.api.send('error:renderer', {
error: {
name: error.name,
message: error.message,
stack: error.stack
},
errorInfo: {
componentStack: errorInfo.componentStack
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
}
handleReload = (): void => {
this.setState({
hasError: false,
errorInfo: null
})
window.location.reload()
}
handleGoHome = (): void => {
this.setState({
hasError: false,
errorInfo: null
})
window.location.hash = '/'
window.location.reload()
}
render(): ReactNode {
if (this.state.hasError && this.state.errorInfo) {
if (this.props.fallback) {
return this.props.fallback(this.state.errorInfo)
}
return (
<ErrorPage
errorInfo={this.state.errorInfo}
onReload={this.handleReload}
onGoHome={this.handleGoHome}
/>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,200 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Textarea } from '@renderer/components/ui/textarea'
import { logger } from '@renderer/lib/logger'
import { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export interface ErrorInfo {
error: Error
errorInfo?: {
componentStack?: string
}
timestamp: number
context?: {
url?: string
userAgent?: string
platform?: string
version?: string
}
}
interface ErrorPageProps {
errorInfo: ErrorInfo
onReload?: () => void
onGoHome?: () => void
}
export function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const errorReport = generateErrorReport(errorInfo)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(errorReport)
setCopied(true)
toast.success(t('error.copySuccess'))
setTimeout(() => setCopied(false), 2000)
} catch (error) {
logger.error('Failed to copy error report:', error)
toast.error(t('error.copyFailed'))
}
}
const handleReload = () => {
if (onReload) {
onReload()
} else {
window.location.reload()
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-background p-4">
<Card className="w-full max-w-3xl">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{t('error.title')}</CardTitle>
<CardDescription className="mt-2">{t('error.description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Error Message */}
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm font-medium text-destructive mb-1">{t('error.message')}</p>
<p className="text-sm text-foreground break-words">
{errorInfo.error.message || t('error.unknownError')}
</p>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
{onGoHome && (
<Button variant="outline" onClick={onGoHome}>
<Home className="h-4 w-4 mr-2" />
{t('error.goHome')}
</Button>
)}
<Button variant="outline" onClick={handleReload}>
<RefreshCw className="h-4 w-4 mr-2" />
{t('error.reload')}
</Button>
<Button variant="outline" onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
{copied ? t('error.copied') : t('error.copyReport')}
</Button>
<Button variant="ghost" onClick={() => setShowDetails(!showDetails)}>
{showDetails ? t('error.hideDetails') : t('error.showDetails')}
</Button>
</div>
{/* Error Details */}
{showDetails && (
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">{t('error.stackTrace')}</p>
<ScrollArea className="h-48 rounded-md border bg-muted/50 p-4">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{errorInfo.error.stack || t('error.noStackTrace')}
</pre>
</ScrollArea>
</div>
{errorInfo.errorInfo?.componentStack && (
<div>
<p className="text-sm font-medium mb-2">{t('error.componentStack')}</p>
<ScrollArea className="h-32 rounded-md border bg-muted/50 p-4">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{errorInfo.errorInfo.componentStack}
</pre>
</ScrollArea>
</div>
)}
<div>
<p className="text-sm font-medium mb-2">{t('error.fullReport')}</p>
<Textarea
readOnly
value={errorReport}
className="font-mono text-xs min-h-48"
onClick={(e) => {
const target = e.target as HTMLTextAreaElement
target.select()
}}
/>
</div>
</div>
)}
{/* Help Text */}
<div className="rounded-md bg-muted/50 border p-4">
<p className="text-sm text-muted-foreground">{t('error.helpText')}</p>
</div>
</CardContent>
</Card>
</div>
)
}
function generateErrorReport(errorInfo: ErrorInfo): string {
const lines: string[] = []
lines.push('=== VidBee Error Report ===')
lines.push(`Timestamp: ${new Date(errorInfo.timestamp).toISOString()}`)
lines.push('')
if (errorInfo.context) {
lines.push('--- Context ---')
if (errorInfo.context.version) {
lines.push(`App Version: ${errorInfo.context.version}`)
}
if (errorInfo.context.platform) {
lines.push(`Platform: ${errorInfo.context.platform}`)
}
if (errorInfo.context.url) {
lines.push(`URL: ${errorInfo.context.url}`)
}
if (errorInfo.context.userAgent) {
lines.push(`User Agent: ${errorInfo.context.userAgent}`)
}
lines.push('')
}
lines.push('--- Error ---')
lines.push(`Name: ${errorInfo.error.name}`)
lines.push(`Message: ${errorInfo.error.message}`)
lines.push('')
if (errorInfo.error.stack) {
lines.push('--- Stack Trace ---')
lines.push(errorInfo.error.stack)
lines.push('')
}
if (errorInfo.errorInfo?.componentStack) {
lines.push('--- Component Stack ---')
lines.push(errorInfo.errorInfo.componentStack)
lines.push('')
}
lines.push('=== End of Report ===')
return lines.join('\n')
}

View File

@@ -0,0 +1,244 @@
import { Button, type ButtonProps } from '@renderer/components/ui/button'
import { ipcServices } from '@renderer/lib/ipc'
import { Github, MessageCircle, Twitter } from 'lucide-react'
import { type MouseEvent, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
type AppInfo = {
appVersion: string
osVersion: string
}
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[Bug]: Download error report'
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
const FEEDBACK_SOURCE_LABEL = 'Source URL'
const FEEDBACK_ERROR_LABEL = 'Error'
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
const FEEDBACK_MAX_GITHUB_URL_LENGTH = 7000
let cachedAppInfo: AppInfo | null = null
let appInfoPromise: Promise<AppInfo> | null = null
const normalizeErrorText = (value?: string | null): string =>
value ? value.replace(/\s+/g, ' ').trim() : ''
const clampText = (value: string, maxLength: number): string =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
const buildIssueLogs = (
errorText: string,
sourceUrl: string | undefined,
ytDlpCommand: string | undefined,
urlLabel: string,
errorLabel: string,
commandLabel: string
): string => {
const lines: string[] = []
if (sourceUrl) {
lines.push(`**${urlLabel}:**\n${sourceUrl}\n`)
}
if (ytDlpCommand) {
lines.push(`**${commandLabel}:**\n\`\`\`bash\n${ytDlpCommand}\n\`\`\`\n`)
}
lines.push(`**${errorLabel}:**\n${errorText}`)
return lines.join('\n')
}
const loadAppInfo = async (): Promise<AppInfo> => {
if (cachedAppInfo) {
return cachedAppInfo
}
if (appInfoPromise) {
return appInfoPromise
}
appInfoPromise = (async () => {
try {
const [version, osRelease] = await Promise.all([
ipcServices.app.getVersion(),
ipcServices.app.getOsVersion()
])
cachedAppInfo = { appVersion: version, osVersion: osRelease }
} catch (error) {
console.error('Failed to load app info for feedback links:', error)
cachedAppInfo = DEFAULT_APP_INFO
}
return cachedAppInfo
})()
return appInfoPromise
}
export const useAppInfo = (): AppInfo => {
const [appInfo, setAppInfo] = useState<AppInfo>(DEFAULT_APP_INFO)
useEffect(() => {
let isActive = true
const loadInfo = async () => {
const info = await loadAppInfo()
if (isActive) {
setAppInfo(info)
}
}
void loadInfo()
return () => {
isActive = false
}
}, [])
return appInfo
}
type FeedbackLinkButtonsProps = {
error?: string | null
sourceUrl?: string | null
issueTitle?: string
includeAppInfo?: boolean
appInfo?: AppInfo
buttonVariant?: ButtonProps['variant']
buttonSize?: ButtonProps['size']
buttonClassName?: string
iconClassName?: string
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
ytDlpCommand?: string
useSimpleGithubUrl?: boolean
}
export const FeedbackLinkButtons = ({
error,
sourceUrl,
issueTitle = '[Bug]: ',
includeAppInfo = false,
appInfo,
buttonVariant = 'outline',
buttonSize = 'sm',
buttonClassName,
iconClassName,
onLinkClick,
ytDlpCommand,
useSimpleGithubUrl = false
}: FeedbackLinkButtonsProps) => {
const { t } = useTranslation()
const fallbackAppInfo = useAppInfo()
const { appVersion, osVersion } = appInfo ?? fallbackAppInfo
const links = useMemo(() => {
const compactError = normalizeErrorText(error)
const tweetError = compactError ? clampText(compactError, 160) : ''
const versionLabels = [
appVersion ? `v${appVersion}` : null,
osVersion ? osVersion : null
].filter(Boolean)
const tweetPrefix = versionLabels.length
? `${FEEDBACK_TWEET_PREFIX} ${versionLabels.join(' ')}`
: FEEDBACK_TWEET_PREFIX
const tweetText = encodeURIComponent(
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
)
const issueError = compactError || FEEDBACK_UNKNOWN_ERROR
const resolvedSourceUrl = sourceUrl?.trim() || undefined
const normalizedCommand = ytDlpCommand?.trim() || undefined
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
const issueLogs = shouldIncludeLogs
? buildIssueLogs(
issueError,
resolvedSourceUrl,
normalizedCommand,
FEEDBACK_SOURCE_LABEL,
FEEDBACK_ERROR_LABEL,
FEEDBACK_COMMAND_LABEL
)
: null
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
let githubUrl: string
if (useSimpleGithubUrl) {
githubUrl = 'https://github.com/nexmoe/VidBee/issues/new/choose'
} else {
const issueParams = new URLSearchParams({
template: 'bug_report.yml',
title: issueTitle
})
if (issueLogs) {
issueParams.set('logs', issueLogs)
}
if (includeAppInfo) {
issueParams.set('app_version', appVersionValue)
issueParams.set('os_version', osVersionValue)
}
githubUrl = `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
}
return [
{
icon: Github,
label: t('about.resources.githubIssues'),
href: githubUrl
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
href: `https://x.com/intent/tweet?text=${tweetText}`
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
href: 'https://discord.gg/uBqXV6QPdm'
}
]
}, [
appVersion,
error,
includeAppInfo,
issueTitle,
osVersion,
sourceUrl,
t,
ytDlpCommand,
useSimpleGithubUrl
])
const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('https://github.com') && href.length >= FEEDBACK_MAX_GITHUB_URL_LENGTH) {
toast.info(t('download.feedback.githubUrlTooLong'))
}
onLinkClick?.(event)
}
return (
<>
{links.map((resource) => {
const Icon = resource.icon
return (
<Button
key={resource.label}
variant={buttonVariant}
size={buttonSize}
className={buttonClassName}
asChild
>
<a
href={resource.href}
target="_blank"
rel="noreferrer"
onClick={(event) => handleLinkClick(event, resource.href)}
>
<Icon className={iconClassName} />
{resource.label}
</a>
</Button>
)
})}
</>
)
}

View File

@@ -1,10 +1,5 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@renderer/components/ui/accordion'
import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -17,6 +12,7 @@ import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Switch } from '@renderer/components/ui/switch'
import { ipcServices } from '@renderer/lib/ipc'
import { cn } from '@renderer/lib/utils'
import { settingsAtom } from '@renderer/store/settings'
import { resolveFeedAtom } from '@renderer/store/subscriptions'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
@@ -85,6 +81,8 @@ export function SubscriptionFormDialog({
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
const urlInputId = useId()
const advancedOptionsId = useId()
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
// Initialize form values based on mode
useEffect(() => {
@@ -92,6 +90,8 @@ export function SubscriptionFormDialog({
return
}
setAdvancedOptionsOpen(false)
if (mode === 'edit' && subscription) {
setUrl(subscription.feedUrl)
setKeywords(subscription.keywords.join(', '))
@@ -274,10 +274,16 @@ export function SubscriptionFormDialog({
</Button>
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
<AccordionContent className="space-y-3">
<div
data-state={advancedOptionsOpen ? 'open' : 'closed'}
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!advancedOptionsOpen}
>
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
<div className="space-y-3 border-t pt-4">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
@@ -299,17 +305,31 @@ export function SubscriptionFormDialog({
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<DialogFooter>
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={advancedOptionsId}
checked={advancedOptionsOpen}
onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}
/>
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
{t('advancedOptions.title')}
</Label>
</div>
<div className="ml-auto flex gap-2">
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,39 @@
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,42 @@
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { cn } from '@renderer/lib/utils'
import { CircleIcon } from 'lucide-react'
import type * as React from 'react'
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -67,6 +67,8 @@ interface RemoteImageProps {
* />
* ```
*/
const IMAGE_LOAD_TIMEOUT_MS = 30000
export function RemoteImage({
src,
alt,
@@ -91,8 +93,10 @@ export function RemoteImage({
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
const [isImageLoading, setIsImageLoading] = useState(true)
const [timedOutSrc, setTimedOutSrc] = useState<string | null>(null)
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
const isLoading = isCacheLoading || isImageLoading
const hasTimedOut = Boolean(src) && timedOutSrc === src
const isLoading = !hasTimedOut && (isCacheLoading || isImageLoading)
useEffect(() => {
if (imageSrc) {
@@ -102,6 +106,19 @@ export function RemoteImage({
}
}, [imageSrc])
useEffect(() => {
if (!src || hasTimedOut || !isLoading) return
const timeoutId = window.setTimeout(() => {
setTimedOutSrc(src)
setIsImageLoading(false)
}, IMAGE_LOAD_TIMEOUT_MS)
return () => {
window.clearTimeout(timeoutId)
}
}, [src, hasTimedOut, isLoading])
useEffect(() => {
onLoadingChange?.(isLoading)
}, [isLoading, onLoadingChange])

View File

@@ -1,22 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { saveSettingAtom } from '@renderer/store/settings'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import { useSetAtom } from 'jotai'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import '../../assets/title-bar.css'
import { updateAvailableAtom } from '@renderer/store/update'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
import MingcuteInformationFill from '~icons/mingcute/information-fill'
import MingcuteInformationLine from '~icons/mingcute/information-line'
import MingcuteRssFill from '~icons/mingcute/rss-fill'
@@ -53,10 +44,8 @@ interface SidebarProps {
}
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = languageList
const { t } = useTranslation()
const [updateAvailable] = useAtom(updateAvailableAtom)
const navigationItems: NavigationItem[] = [
{
@@ -105,20 +94,6 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
}
]
const activeLanguageCode = normalizeLanguageCode(i18n.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18n.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
const isActive = item.id !== 'supported-sites' && currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
@@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
<div className="flex-1" />
{/* Language Selector */}
<div className="flex flex-col items-center gap-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t('settings.language')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="end">
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
aria-current={isActive}
>
<div className="flex items-center gap-2">
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
<span lang={option.hreflang}>{option.name}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bottom Navigation Items */}
{bottomNavigationItems.map((item) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
const showUpdateDot = item.id === 'about' && updateAvailable.available
return (
<div key={item.id} className="flex flex-col items-center gap-1">
@@ -211,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag rounded-2xl w-12 h-12 relative ${
isActive ? 'bg-primary/10' : ''
}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
{showUpdateDot ? (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="right">

View File

@@ -0,0 +1,89 @@
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -1,98 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useTranslation } from 'react-i18next'
import type { VideoInfo } from '../../../../shared/types'
export interface AudioExtractorState {
extractFormat: string
extractQuality: string
}
interface AudioExtractorProps {
videoInfo: VideoInfo
state: AudioExtractorState
onStateChange: (state: Partial<AudioExtractorState>) => void
}
export function AudioExtractor({
videoInfo: _videoInfo,
state,
onStateChange
}: AudioExtractorProps) {
const { t } = useTranslation()
const { extractFormat, extractQuality } = state
const audioFormats = [
{ value: 'mp3', label: 'MP3' },
{ value: 'm4a', label: 'M4A' },
{ value: 'opus', label: 'Opus' },
{ value: 'wav', label: 'WAV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'alac', label: 'ALAC' },
{ value: 'vorbis', label: 'Vorbis (OGG)' }
]
const qualities = [
{ value: '0', label: t('audioExtract.best') },
{ value: '2', label: t('audioExtract.good') },
{ value: '5', label: t('audioExtract.normal') },
{ value: '8', label: t('audioExtract.bad') },
{ value: '10', label: t('audioExtract.worst') }
]
return (
<Card className="border-2 border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
<Select
value={extractFormat}
onValueChange={(value) => onStateChange({ extractFormat: value })}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
<Select
value={extractQuality}
onValueChange={(value) => onStateChange({ extractQuality: value })}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,306 +0,0 @@
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useAtom } from 'jotai'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
import { settingsAtom } from '../../store/settings'
interface FormatSelectorProps {
formats: VideoFormat[]
type: 'video' | 'audio'
onVideoFormatChange?: (format: string) => void
onAudioFormatChange?: (format: string) => void
}
export function FormatSelector({
formats,
type,
onVideoFormatChange,
onAudioFormatChange
}: FormatSelectorProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
const [selectedVideo, setSelectedVideo] = useState('')
const [selectedAudio, setSelectedAudio] = useState('')
const pickVideoFormatForPreset = useCallback(
(formats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
if (formats.length === 0) {
return null
}
const heightLimit = qualityPresetToVideoHeight[preset]
const byHeightDescending = (a: VideoFormat, b: VideoFormat) =>
(b.height ?? 0) - (a.height ?? 0)
const sorted = [...formats].sort(byHeightDescending)
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
if (!heightLimit) {
return sorted[0]
}
const matchingLimit = sorted.find((format) => {
if (!format.height) return false
return format.height <= heightLimit
})
return matchingLimit ?? sorted[0]
},
[]
)
useEffect(() => {
// Filter and sort formats
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
const videos = formats.filter(
(f) =>
f.video_ext !== 'none' &&
f.vcodec &&
f.vcodec !== 'none' &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
const audios = formats.filter(
(f) =>
f.acodec &&
f.acodec !== 'none' &&
(f.video_ext === 'none' || !f.video_ext) &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
// Apply showMoreFormats filter
const filteredVideos = settings.showMoreFormats
? videos
: videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp'))
const filteredAudios = settings.showMoreFormats
? audios
: audios.filter((f) => f.ext !== 'webm')
// Sort formats by quality (best first)
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by height (higher is better)
const aHeight = a.height ?? 0
const bHeight = b.height ?? 0
if (aHeight !== bHeight) {
return bHeight - aHeight
}
// If same height, sort by fps (higher is better)
const aFps = a.fps ?? 0
const bFps = b.fps ?? 0
if (aFps !== bFps) {
return bFps - aFps
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by bitrate/quality if available
const aQuality = a.tbr ?? a.quality ?? 0
const bQuality = b.tbr ?? b.quality ?? 0
if (aQuality !== bQuality) {
return bQuality - aQuality
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
filteredVideos.sort(sortVideoFormatsByQuality)
filteredAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
if (preferred) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
}, [
formats,
settings,
selectedVideo,
selectedAudio,
onAudioFormatChange,
onVideoFormatChange,
pickVideoFormatForPreset
])
const formatSize = (bytes?: number) => {
if (!bytes) return t('download.unknownSize')
const mb = bytes / 1000000
return `${mb.toFixed(2)} MB`
}
const formatVideoLabel = (format: VideoFormat) => {
const parts: string[] = []
// Resolution
if (format.height) {
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
}
// Format extension
parts.push(format.ext.toUpperCase())
// Codec (if showMoreFormats is enabled)
if (settings.showMoreFormats && format.vcodec) {
parts.push(format.vcodec.split('.')[0])
}
// Audio indicator
if (format.acodec !== 'none') {
parts.push('🔊')
}
// File size
const size = formatSize(format.filesize || format.filesize_approx)
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
const formatAudioLabel = (format: VideoFormat) => {
const parts: string[] = []
// Quality
const quality = format.format_note || t('download.unknownQuality')
parts.push(quality)
// Format extension
const ext = format.ext === 'webm' ? 'opus' : format.ext
parts.push(ext.toUpperCase())
// File size
const size = formatSize(format.filesize || format.filesize_approx)
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
if (type === 'video') {
return (
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
// Audio only
return (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@@ -1,162 +0,0 @@
import { Badge } from '@renderer/components/ui/badge'
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Separator } from '@renderer/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Clock, Eye, Play } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { VideoInfo } from '../../../../shared/types'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
import { FormatSelector } from './FormatSelector'
export interface VideoInfoCardState {
title: string
activeTab: 'video' | 'audio'
selectedVideoFormat: string
selectedAudioForVideo: string
selectedAudioFormat: string
startTime: string
endTime: string
downloadSubs: boolean
customDownloadPath: string
audioExtractor: AudioExtractorState
}
interface VideoInfoCardProps {
videoInfo: VideoInfo
state: VideoInfoCardState
onStateChange: (state: Partial<VideoInfoCardState>) => void
onTabChange: (tab: 'video' | 'audio') => void
}
function formatDuration(seconds?: number): string {
if (!seconds) return 'Unknown'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatViews(views?: number): string {
if (!views) return 'Unknown'
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M`
if (views >= 1000) return `${(views / 1000).toFixed(1)}K`
return views.toString()
}
export function VideoInfoCard({
videoInfo,
state,
onStateChange,
onTabChange
}: VideoInfoCardProps) {
const { t } = useTranslation()
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
const { title, activeTab } = state
return (
<div>
<Card className="overflow-hidden border shadow-sm">
<CardHeader className="p-3">
<div className="flex gap-3">
{/* Thumbnail */}
<div className="shrink-0">
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
/>
</div>
</div>
{/* Video Metadata */}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-wrap gap-1.5 items-center">
<Badge
variant="outline"
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
>
{videoInfo.extractor_key || t('download.videoInfo')}
</Badge>
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
<Clock className="h-2.5 w-2.5" />
<span>{formatDuration(videoInfo.duration)}</span>
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
<Eye className="h-2.5 w-2.5" />
<span>{formatViews(videoInfo.view_count)}</span>
</Badge>
)}
</div>
<div className="space-y-1">
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
{videoInfo.uploader && (
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
)}
</div>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="p-3 pt-3">
<Tabs
value={activeTab}
onValueChange={(v) => {
onTabChange(v as 'video' | 'audio')
onStateChange({ activeTab: v as 'video' | 'audio' })
}}
className="w-full"
>
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
<TabsTrigger value="video" className="text-xs">
{t('download.video')}
</TabsTrigger>
<TabsTrigger value="audio" className="text-xs">
{t('download.audio')}
</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-3 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
/>
</TabsContent>
<TabsContent value="audio" className="space-y-3 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
/>
<AudioExtractor
videoInfo={videoInfo}
state={state.audioExtractor}
onStateChange={(updates) =>
onStateChange({
audioExtractor: { ...state.audioExtractor, ...updates }
})
}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import { useSetAtom } from 'jotai'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcEvents, ipcServices } from '../lib/ipc'
import {
addDownloadAtom,
addHistoryRecordAtom,
removeDownloadAtom,
updateDownloadAtom
} from '../store/downloads'
const isFinalStatus = (status?: string): boolean =>
status === 'completed' || status === 'error' || status === 'cancelled'
export function useDownloadEvents() {
const updateDownload = useSetAtom(updateDownloadAtom)
const addDownload = useSetAtom(addDownloadAtom)
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const { t } = useTranslation()
const syncHistoryItem = useCallback(
async (id: string) => {
try {
const historyItem = await ipcServices.history.getHistoryById(id)
if (!historyItem) {
return
}
addHistoryRecord(historyItem)
if (isFinalStatus(historyItem.status)) {
removeDownload(id)
}
} catch (error) {
console.error('Failed to sync history item:', error)
}
},
[addHistoryRecord, removeDownload]
)
useEffect(() => {
const syncActiveDownloads = async () => {
try {
const activeDownloads = await ipcServices.download.getActiveDownloads()
activeDownloads.forEach((item) => {
addDownload(item)
})
} catch (error) {
console.error('Failed to load active downloads:', error)
}
}
void syncActiveDownloads()
}, [addDownload])
useEffect(() => {
const handleStarted = (rawId: unknown) => {
const id = typeof rawId === 'string' ? rawId : ''
if (!id) {
return
}
updateDownload({
id,
changes: {
status: 'downloading',
startedAt: Date.now()
}
})
}
const handleProgress = (rawData: unknown) => {
const data = rawData as { id?: string; progress?: unknown }
const id = typeof data?.id === 'string' ? data.id : ''
if (!id) {
return
}
const progress = (data.progress ?? {}) as {
percent?: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
updateDownload({
id,
changes: {
progress: {
percent: typeof progress.percent === 'number' ? progress.percent : 0,
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
},
speed: progress.currentSpeed || ''
}
})
}
const handleCompleted = (rawId: unknown) => {
const id = typeof rawId === 'string' ? rawId : ''
if (!id) {
return
}
updateDownload({ id, changes: { status: 'completed', completedAt: Date.now() } })
toast.success(t('notifications.downloadCompleted'))
void syncHistoryItem(id)
}
const handleError = (rawData: unknown) => {
const data = rawData as { id?: string; error?: string }
const id = typeof data?.id === 'string' ? data.id : ''
if (!id) {
return
}
const errorMessage = typeof data?.error === 'string' ? data.error : ''
updateDownload({ id, changes: { status: 'error', error: errorMessage } })
toast.error(t('notifications.downloadFailed'))
void syncHistoryItem(id)
}
const handleCancelled = (rawId: unknown) => {
const id = typeof rawId === 'string' ? rawId : ''
if (!id) {
return
}
updateDownload({ id, changes: { status: 'cancelled', completedAt: Date.now() } })
void syncHistoryItem(id)
}
const startedSubscription = ipcEvents.on('download:started', handleStarted)
const progressSubscription = ipcEvents.on('download:progress', handleProgress)
const completedSubscription = ipcEvents.on('download:completed', handleCompleted)
const errorSubscription = ipcEvents.on('download:error', handleError)
const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled)
return () => {
ipcEvents.removeListener('download:started', startedSubscription)
ipcEvents.removeListener('download:progress', progressSubscription)
ipcEvents.removeListener('download:completed', completedSubscription)
ipcEvents.removeListener('download:error', errorSubscription)
ipcEvents.removeListener('download:cancelled', cancelledSubscription)
}
}, [syncHistoryItem, t, updateDownload])
}

View File

@@ -0,0 +1,20 @@
/**
* Renderer process logger utility
* Use electron-log/renderer which automatically forwards logs to main process
*/
import log from 'electron-log/renderer'
// Export electron-log instance
export default log
// Export commonly used logging methods
export const logger = log
// Predefined scoped loggers
export const scopedLoggers = {
renderer: log.scope('renderer'),
error: log.scope('error'),
component: log.scope('component'),
api: log.scope('api')
}

View File

@@ -51,6 +51,12 @@
"documentationDescription": "أدلة، أسئلة شائعة، وسير عمل شائعة.",
"feedback": "ملاحظات ومشاكل",
"feedbackDescription": "شارك الأفكار أو أبلغ عن المشاكل على GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "الإبلاغ عن الأخطاء أو طلب الميزات على GitHub.",
"xFeedback": "تويتر",
"xFeedbackDescription": "شارك الملاحظات أو الاقتراحات على X بذكر @nexmoex.",
"discord": "Discord",
"discordDescription": "انضم إلى مجتمع Discord للنقاش والدعم.",
"license": "الترخيص",
"licenseDescription": "راجع شروط ترخيص المصدر المفتوح.",
"website": "الموقع الرسمي",
@@ -83,6 +89,7 @@
"currentLocation": "موقع التحميل الحالي - ",
"downloadLocation": "موقع التحميل",
"downloadSubs": "تحميل الترجمات إن كانت متاحة",
"downloadSubsHint": "احفظ الترجمات كملفات منفصلة عند توفرها",
"end": "النهاية",
"endHint": "إذا تُرك فارغاً، سيتم التحميل حتى النهاية",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "تحميل الفيديوهات والصوتيات من مئات المواقع",
"title": "VidBee"
},
"audioExtract": {
"bad": "سيء",
"best": "أفضل",
"extract": "استخراج",
"good": "جيد",
"normal": "عادي",
"selectFormat": "اختر التنسيق",
"selectQuality": "اختر الجودة",
"title": "استخراج الصوت",
"worst": "أسوأ"
},
"download": {
"active": "نشط",
"all": "الكل",
@@ -123,6 +119,10 @@
"downloadBtn": "تحميل",
"downloadPending": "قيد الانتظار",
"downloadQueue": "قائمة انتظار التحميل",
"customDownloadFolder": "مجلد تنزيل مخصص",
"autoFolderPlaceholder": "مجلد تلقائي (استنادًا إلى البيانات الوصفية)",
"autoFolderHint": "يتم إنشاء المجلدات التلقائية من البيانات الوصفية.",
"useAutoFolder": "استخدام المجلد التلقائي",
"downloadVideo": "تحميل الفيديو",
"downloading": "جاري التحميل...",
"enterUrl": "أدخل رابط الفيديو",
@@ -149,15 +149,17 @@
"paste": "لصق",
"pastePlaylistUrl": "انقر للصق رابط قائمة التشغيل من الحافظة [Ctrl + V]",
"pasteUrl": "انقر للصق رابط أو معرف الفيديو [Ctrl + V]",
"pasteUrlButton": "لصق الرابط",
"preparing": "جاري التحضير...",
"processing": "جاري المعالجة",
"progress": "التقدم",
"showDetails": "إظهار التفاصيل",
"hideDetails": "إخفاء التفاصيل",
"selectAudioFormat": "اختر تنسيق الصوت",
"selectDownloadType": "اختر نوع التنزيل",
"selectFormat": "اختر التنسيق",
"selectVideoFormat": "اختر تنسيق الفيديو",
"startDownload": "بدء التحميل",
"selectVideoFormat": "اختر تنسيق الفيديو",
"singleVideo": "فيديو واحد",
"speed": "السرعة",
"title": "العنوان",
@@ -193,8 +195,31 @@
"formatNote": "ملاحظة التنسيق",
"protocol": "البروتوكول",
"subscription": "الاشتراك"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "رابط GitHub هذا طويل جدًا. إذا لم يفتح، فالرجاء فتح صفحة المشكلة ولصق السجلات يدويًا."
}
},
"error": {
"title": "حدث خطأ ما",
"description": "حدث خطأ غير متوقع. يرجى إعادة تحميل التطبيق أو الإبلاغ عن هذه المشكلة إذا استمرت.",
"message": "رسالة الخطأ",
"unknownError": "حدث خطأ غير معروف",
"goHome": "العودة إلى الصفحة الرئيسية",
"reload": "إعادة تحميل التطبيق",
"copyReport": "نسخ تقرير الخطأ",
"copied": "تم النسخ!",
"copySuccess": "تم نسخ تقرير الخطأ إلى الحافظة",
"copyFailed": "فشل نسخ تقرير الخطأ",
"showDetails": "إظهار التفاصيل",
"hideDetails": "إخفاء التفاصيل",
"stackTrace": "تتبع المكدس",
"componentStack": "مكدس المكوّن",
"noStackTrace": "لا يوجد تتبع مكدس متاح",
"fullReport": "تقرير الخطأ الكامل",
"helpText": "إذا استمر هذا الخطأ، يرجى نسخ تقرير الخطأ أعلاه ومشاركته مع فريق الدعم. يمكنك العثور على معلومات الاتصال في صفحة \"حول\"."
},
"errors": {
"clickToCopy": "انقر لنسخ التفاصيل",
"clipboardEmpty": "الحافظة فارغة",
@@ -203,6 +228,7 @@
"emptyUrl": "يرجى إدخال رابط",
"errorDetails": "تفاصيل الخطأ",
"fetchInfoFailed": "فشل في جلب معلومات الفيديو",
"invalidUrl": "محتوى الحافظة ليس رابطًا صالحًا",
"networkError": "حدث خطأ ما. تحقق من شبكتك واستخدم رابطاً صحيحاً",
"pasteFromClipboard": "فشل في اللصق من الحافظة"
},
@@ -210,10 +236,23 @@
"clearCancelled": "مسح الملغاة",
"clearCompleted": "مسح المكتملة",
"clearErrors": "مسح الأخطاء",
"clearAll": "مسح كل السجل",
"clearAllAction": "مسح السجل",
"clearSelection": "مسح التحديد",
"confirmClearAllTitle": "مسح كل السجل؟",
"confirmClearAllDescription": "إزالة {{count}} عنصرًا من السجل. تبقى الملفات على القرص.",
"confirmDeleteSelectedTitle": "إزالة العناصر المحددة؟",
"confirmDeleteSelectedDescription": "إزالة {{count}} عنصرًا من السجل. تبقى الملفات على القرص.",
"alsoDeleteFiles": "احذف الملفات أيضًا",
"confirmDeletePlaylistTitle": "إزالة سجل قائمة التشغيل؟",
"confirmDeletePlaylistDescription": "إزالة {{count}} عنصرًا من {{title}} وحذف ملفاتها.",
"copyToClipboard": "نسخ إلى الحافظة",
"copyUrl": "نسخ الرابط",
"date": "التاريخ",
"deletePlaylist": "إزالة قائمة التشغيل",
"deleteSelected": "إزالة المحدد",
"description": "عرض وإدارة سجل التحميل الخاص بك",
"doneSelecting": "تم",
"duration": "المدة",
"fileSize": "حجم الملف",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "فتح موقع الملف",
"openFolder": "فتح المجلد",
"openInBrowser": "انقر لفتح في المتصفح",
"removeAction": "إزالة",
"removeItem": "إزالة العنصر",
"select": "تحديد",
"selectAll": "تحديد الكل",
"selectVisible": "تحديد المرئي",
"selectItem": "تحديد العنصر",
"selectedCount": "تم تحديد {{count}}",
"selectionSummary": "تم تحديد {{selected}} من {{total}} المرئية",
"stats": {
"cancelled": "ملغي",
"completed": "مكتمل",
@@ -258,9 +304,15 @@
"downloadCompleted": "اكتمل التحميل",
"downloadFailed": "فشل التحميل",
"downloadStarted": "بدأ التحميل",
"historyCleared": "تم مسح السجل",
"historyClearFailed": "فشل مسح السجل",
"itemRemoved": "تم إزالة العنصر",
"itemsRemoved": "تمت إزالة {{count}} عنصرًا",
"itemsRemoveFailed": "فشل إزالة العناصر المحددة",
"openFileFailed": "فشل في فتح الملف",
"openFolderFailed": "فشل في فتح المجلد",
"playlistHistoryRemoved": "تمت إزالة قائمة التشغيل وحذف الملفات",
"playlistHistoryRemoveFailed": "فشل إزالة سجل قائمة التشغيل",
"removeFailed": "فشل في إزالة العنصر",
"settingsSaved": "تم حفظ الإعدادات",
"urlCopied": "تم نسخ الرابط إلى الحافظة",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "قائمة التشغيل",
"clearPreview": "مسح المعاينة",
"collapsedProgress": "جارٍ تنزيل قائمة التشغيل: {{completed}} / {{total}} مكتملة",
"comingSoon": "ميزة تحميل قائمة التشغيل قريباً!",
"completed": "تم تحميل قائمة التشغيل",
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
@@ -284,7 +337,9 @@
"folderFormat": "تنسيق اسم المجلد لقوائم التشغيل",
"foundVideos": "تم العثور على {{count}} فيديو في قائمة التشغيل",
"groupActive": "{{count}} نشط",
"groupCollapse": "طي",
"groupErrors": "{{count}} فشل",
"groupExpand": "توسيع",
"groupSummary": "{{completed}} / {{total}} مكتمل",
"linkLabel": "رابط قائمة التشغيل",
"noEntries": "لم يتم العثور على فيديوهات في قائمة التشغيل هذه",
@@ -299,7 +354,11 @@
"range": "النطاق (اختياري)",
"resetToDefault": "إعادة تعيين إلى الافتراضي",
"selectedRange": "النطاق: {{start}}-{{end}}",
"selectedVideos": "تم تحديد {{count}}",
"downloadCurrentRange": "تنزيل المحدد",
"showingCount": "عرض {{count}} فيديو",
"selectEntry": "تحديد الإدخال {{index}}",
"noEntriesSelected": "لا توجد إدخالات محددة",
"startIndex": "البداية (1)",
"title": "تحميل قائمة التشغيل",
"totalVideos": "إجمالي الفيديوهات: {{count}}",
@@ -312,6 +371,15 @@
"audio": "تفضيلات الصوت",
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
"browserForCookiesWindowsNote": "في Windows، يتم دعم ملفات تعريف الارتباط من Firefox فقط. للمتصفحات الأخرى، يرجى إعداد ملف ملفات تعريف الارتباط يدويًا.",
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
"browserForCookiesProfileInvalid": "مسار الملف الشخصي غير صالح. اختر مجلد الملف الشخصي للمتصفح المحدد.",
"browserForCookiesProfileInvalidPath": "هذا المجلد غير موجود. اختر مجلد ملف شخصي موجود.",
"browserForCookiesProfileInvalidProfile": "لم يتم العثور على اسم الملف الشخصي في موقع المتصفح الافتراضي.",
"browserForCookiesProfileInvalidUnsupported": "لا يوجد موقع ملف شخصي افتراضي معروف لهذا المتصفح على هذه المنصة.",
"browserForCookiesProfileInvalidEmpty": "أدخل مسار ملف شخصي للمتصفح المحدد.",
"cookiesFile": "ملف ملفات تعريف الارتباط",
"cookiesFileDescription": "ملف ملفات تعريف الارتباط بتنسيق Netscape للتحميل للمصادقة",
"clearCookiesFile": "مسح",
@@ -323,9 +391,13 @@
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "استخدام ملف التكوين",
"configFileDescription": "ملف تكوين مخصص لـ yt-dlp",
@@ -338,6 +410,7 @@
"fileSelectError": "فشل في اختيار الملف",
"general": "عام",
"language": "اللغة",
"languageDescription": "اختر لغتك المفضلة لواجهة التطبيق",
"light": "فاتح",
"hideDockIcon": "إخفاء أيقونة Dock",
"hideDockIconDescription": "إزالة VidBee من Dock في macOS. استخدم شريط القائمة أو أيقونة الدرج لإعادة فتح التطبيق.",
@@ -346,6 +419,14 @@
"launchAtLoginUnsupported": "البدء التلقائي متاح فقط على macOS و Windows.",
"enableAnalytics": "مساعدة في تحسين VidBee",
"enableAnalyticsDescription": "مشاركة بيانات الاستخدام المجهولة لمساعدتنا في فهم كيفية استخدام التطبيق وأولويات التحسينات.",
"embedChapters": "تضمين الفصول",
"embedChaptersDescription": "إضافة علامات الفصول إلى الملف عند توفرها",
"embedMetadata": "تضمين البيانات الوصفية",
"embedMetadataDescription": "كتابة العنوان والفنان وبيانات وصفية أخرى عند توفرها",
"embedSubs": "تضمين الترجمات",
"embedSubsDescription": "تضمين الترجمات داخل ملف الفيديو (mp4، webm، mkv)",
"embedThumbnail": "تضمين الصورة المصغرة",
"embedThumbnailDescription": "إضافة الصورة المصغرة كغلاف",
"maxConcurrentDownloads": "العدد الأقصى للتحميلات النشطة",
"maxConcurrentDownloadsDescription": "العدد الأقصى للتحميلات المتزامنة",
"none": "لا شيء",
@@ -371,8 +452,7 @@
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
"subscriptionDefaults": {
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
},
"system": "النظام",
"theme": "المظهر",
@@ -393,7 +473,6 @@
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
"downloadDirectory": "مجلد التحميل",
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
"checkInterval": "فترة الفحص (ساعات)",
"onlyLatest": "تحميل أحدث فيديو فقط",
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
},
@@ -468,7 +547,8 @@
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
"openLinkError": "فشل في فتح رابط الفيديو.",
"resolveError": "فشل في حل رابط تغذية RSS."
"resolveError": "فشل في حل رابط تغذية RSS.",
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
},
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
"detecting": "جاري اكتشاف التغذية...",

View File

@@ -51,6 +51,12 @@
"documentationDescription": "Anleitungen, FAQs und gängige Arbeitsabläufe.",
"feedback": "Feedback & Probleme",
"feedbackDescription": "Teilen Sie Ideen oder melden Sie Probleme auf GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Fehler melden oder Funktionen auf GitHub anfordern.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Feedback oder Vorschläge auf X teilen, indem @nexmoex erwähnt wird.",
"discord": "Discord",
"discordDescription": "Tritt unserer Discord-Community für Diskussionen und Support bei.",
"license": "Lizenz",
"licenseDescription": "Überprüfen Sie die Bedingungen der Open-Source-Lizenz.",
"website": "Offizielle Website",
@@ -83,6 +89,7 @@
"currentLocation": "Aktueller Download-Speicherort - ",
"downloadLocation": "Download-Speicherort",
"downloadSubs": "Untertitel herunterladen, falls verfügbar",
"downloadSubsHint": "Untertitel als separate Dateien speichern, wenn verfügbar",
"end": "Ende",
"endHint": "Wenn leer gelassen, wird bis zum Ende heruntergeladen",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Videos und Audios von Hunderten von Websites herunterladen",
"title": "VidBee"
},
"audioExtract": {
"bad": "Schlecht",
"best": "Am besten",
"extract": "Extrahieren",
"good": "Gut",
"normal": "Normal",
"selectFormat": "Format auswählen",
"selectQuality": "Qualität auswählen",
"title": "Audio extrahieren",
"worst": "Am schlechtesten"
},
"download": {
"active": "Aktiv",
"all": "Alle",
@@ -123,6 +119,10 @@
"downloadBtn": "Herunterladen",
"downloadPending": "Ausstehend",
"downloadQueue": "Download-Warteschlange",
"customDownloadFolder": "Benutzerdefinierter Download-Ordner",
"autoFolderPlaceholder": "Automatischer Ordner (basierend auf Metadaten)",
"autoFolderHint": "Automatische Ordner werden aus Metadaten erstellt.",
"useAutoFolder": "Automatischen Ordner verwenden",
"downloadVideo": "Video herunterladen",
"downloading": "Wird heruntergeladen...",
"enterUrl": "Video-URL eingeben",
@@ -149,15 +149,17 @@
"paste": "Einfügen",
"pastePlaylistUrl": "Klicken, um Playlist-Link aus Zwischenablage einzufügen [Strg + V]",
"pasteUrl": "Klicken, um Video-URL oder ID einzufügen [Strg + V]",
"pasteUrlButton": "URL einfügen",
"preparing": "Wird vorbereitet...",
"processing": "Wird verarbeitet",
"progress": "Fortschritt",
"showDetails": "Details anzeigen",
"hideDetails": "Details ausblenden",
"selectAudioFormat": "Audio-Format auswählen",
"selectDownloadType": "Download-Typ auswählen",
"selectFormat": "Format auswählen",
"selectVideoFormat": "Video-Format auswählen",
"startDownload": "Download starten",
"selectVideoFormat": "Video-Format auswählen",
"singleVideo": "Einzelnes Video",
"speed": "Geschwindigkeit",
"title": "Titel",
@@ -193,8 +195,31 @@
"formatNote": "Format-Hinweis",
"protocol": "Protokoll",
"subscription": "Abonnement"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "Dieser GitHub-Link ist sehr lang. Wenn er sich nicht öffnet, öffnen Sie bitte die Issue-Seite und fügen Sie die Logs manuell ein."
}
},
"error": {
"title": "Etwas ist schiefgelaufen",
"description": "Ein unerwarteter Fehler ist aufgetreten. Bitte lade die App neu oder melde das Problem, wenn es weiterhin besteht.",
"message": "Fehlermeldung",
"unknownError": "Unbekannter Fehler ist aufgetreten",
"goHome": "Zur Startseite",
"reload": "App neu laden",
"copyReport": "Fehlerbericht kopieren",
"copied": "Kopiert!",
"copySuccess": "Fehlerbericht in die Zwischenablage kopiert",
"copyFailed": "Fehlerbericht konnte nicht kopiert werden",
"showDetails": "Details anzeigen",
"hideDetails": "Details ausblenden",
"stackTrace": "Stacktrace",
"componentStack": "Komponenten-Stack",
"noStackTrace": "Kein Stacktrace verfügbar",
"fullReport": "Vollständiger Fehlerbericht",
"helpText": "Wenn dieser Fehler weiterhin auftritt, kopiere bitte den obigen Fehlerbericht und teile ihn mit dem Support-Team. Kontaktinformationen findest du auf der Seite \"Über\"."
},
"errors": {
"clickToCopy": "Klicken, um Details zu kopieren",
"clipboardEmpty": "Zwischenablage ist leer",
@@ -203,6 +228,7 @@
"emptyUrl": "Bitte geben Sie eine URL ein",
"errorDetails": "Fehlerdetails",
"fetchInfoFailed": "Video-Informationen konnten nicht abgerufen werden",
"invalidUrl": "Der Inhalt der Zwischenablage ist keine gültige URL",
"networkError": "Ein Fehler ist aufgetreten. Überprüfen Sie Ihr Netzwerk und verwenden Sie die richtige URL",
"pasteFromClipboard": "Einfügen aus Zwischenablage fehlgeschlagen"
},
@@ -210,10 +236,23 @@
"clearCancelled": "Abgebrochene löschen",
"clearCompleted": "Abgeschlossene löschen",
"clearErrors": "Fehler löschen",
"clearAll": "Gesamten Verlauf löschen",
"clearAllAction": "Verlauf löschen",
"clearSelection": "Auswahl löschen",
"confirmClearAllTitle": "Gesamten Verlauf löschen?",
"confirmClearAllDescription": "{{count}} Elemente aus deinem Verlauf entfernen. Dateien bleiben auf der Festplatte.",
"confirmDeleteSelectedTitle": "Ausgewählte Elemente entfernen?",
"confirmDeleteSelectedDescription": "{{count}} Elemente aus deinem Verlauf entfernen. Dateien bleiben auf der Festplatte.",
"alsoDeleteFiles": "Dateien ebenfalls löschen",
"confirmDeletePlaylistTitle": "Playlist-Verlauf entfernen?",
"confirmDeletePlaylistDescription": "{{count}} Elemente aus {{title}} entfernen und ihre Dateien löschen.",
"copyToClipboard": "In Zwischenablage kopieren",
"copyUrl": "URL kopieren",
"date": "Datum",
"deletePlaylist": "Playlist entfernen",
"deleteSelected": "Auswahl entfernen",
"description": "Ihren Download-Verlauf anzeigen und verwalten",
"doneSelecting": "Fertig",
"duration": "Dauer",
"fileSize": "Dateigröße",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "Dateispeicherort öffnen",
"openFolder": "Ordner öffnen",
"openInBrowser": "Klicken, um im Browser zu öffnen",
"removeAction": "Entfernen",
"removeItem": "Element entfernen",
"select": "Auswählen",
"selectAll": "Alle auswählen",
"selectVisible": "Sichtbare auswählen",
"selectItem": "Element auswählen",
"selectedCount": "{{count}} ausgewählt",
"selectionSummary": "{{selected}} von {{total}} sichtbaren ausgewählt",
"stats": {
"cancelled": "Abgebrochen",
"completed": "Abgeschlossen",
@@ -258,9 +304,15 @@
"downloadCompleted": "Download abgeschlossen",
"downloadFailed": "Download fehlgeschlagen",
"downloadStarted": "Download gestartet",
"historyCleared": "Verlauf gelöscht",
"historyClearFailed": "Verlauf konnte nicht gelöscht werden",
"itemRemoved": "Element entfernt",
"itemsRemoved": "{{count}} Elemente entfernt",
"itemsRemoveFailed": "Ausgewählte Elemente konnten nicht entfernt werden",
"openFileFailed": "Datei konnte nicht geöffnet werden",
"openFolderFailed": "Ordner konnte nicht geöffnet werden",
"playlistHistoryRemoved": "Playlist entfernt und Dateien gelöscht",
"playlistHistoryRemoveFailed": "Playlist-Verlauf konnte nicht entfernt werden",
"removeFailed": "Element konnte nicht entfernt werden",
"settingsSaved": "Einstellungen gespeichert",
"urlCopied": "URL in Zwischenablage kopiert",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Vorschau löschen",
"collapsedProgress": "Playlist wird heruntergeladen: {{completed}} / {{total}} abgeschlossen",
"comingSoon": "Playlist-Download-Funktion kommt bald!",
"completed": "Playlist heruntergeladen",
"description": "Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen",
@@ -284,7 +337,9 @@
"folderFormat": "Ordnernamenformat für Playlists",
"foundVideos": "{{count}} Videos in Playlist gefunden",
"groupActive": "{{count}} aktiv",
"groupCollapse": "Einklappen",
"groupErrors": "{{count}} fehlgeschlagen",
"groupExpand": "Ausklappen",
"groupSummary": "{{completed}} / {{total}} abgeschlossen",
"linkLabel": "Playlist-URL",
"noEntries": "In dieser Playlist wurden keine Videos gefunden",
@@ -299,7 +354,11 @@
"range": "Bereich (Optional)",
"resetToDefault": "Auf Standard zurücksetzen",
"selectedRange": "Bereich: {{start}}-{{end}}",
"selectedVideos": "{{count}} ausgewählt",
"downloadCurrentRange": "Auswahl herunterladen",
"showingCount": "{{count}} Videos werden angezeigt",
"selectEntry": "Eintrag {{index}} auswählen",
"noEntriesSelected": "Keine Einträge ausgewählt",
"startIndex": "Start (1)",
"title": "Playlist herunterladen",
"totalVideos": "Gesamt Videos: {{count}}",
@@ -312,6 +371,15 @@
"audio": "Audio-Einstellungen",
"browserForCookies": "Browser für Cookies auswählen",
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
"browserForCookiesWindowsNote": "Unter Windows werden nur Firefox-Cookies unterstützt. Für andere Browser richten Sie bitte manuell eine Cookie-Datei ein.",
"browserForCookiesProfile": "Profilname oder Pfad",
"browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.",
"browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)",
"browserForCookiesProfileInvalid": "Profilpfad ist ungültig. Wähle den Profilordner für den ausgewählten Browser.",
"browserForCookiesProfileInvalidPath": "Dieser Ordner existiert nicht. Wähle einen vorhandenen Profilordner.",
"browserForCookiesProfileInvalidProfile": "Profilname am Standard-Browserort nicht gefunden.",
"browserForCookiesProfileInvalidUnsupported": "Für diesen Browser ist auf dieser Plattform kein Standard-Profilort bekannt.",
"browserForCookiesProfileInvalidEmpty": "Gib einen Profilpfad für den ausgewählten Browser ein.",
"cookiesFile": "Cookie-Datei",
"cookiesFileDescription": "Netscape-formatierte Cookie-Datei zum Laden für die Authentifizierung",
"clearCookiesFile": "Löschen",
@@ -323,9 +391,13 @@
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Konfigurationsdatei verwenden",
"configFileDescription": "Benutzerdefinierte Konfigurationsdatei für yt-dlp",
@@ -338,6 +410,7 @@
"fileSelectError": "Datei konnte nicht ausgewählt werden",
"general": "Allgemein",
"language": "Sprache",
"languageDescription": "Wähle deine bevorzugte Sprache für die App-Oberfläche",
"light": "Hell",
"hideDockIcon": "Dock-Symbol ausblenden",
"hideDockIconDescription": "VidBee aus dem macOS Dock entfernen. Verwenden Sie die Menüleiste oder das Tray-Symbol, um die App erneut zu öffnen.",
@@ -346,6 +419,14 @@
"launchAtLoginUnsupported": "Autostart ist nur unter macOS und Windows verfügbar.",
"enableAnalytics": "Helfen Sie, VidBee zu verbessern",
"enableAnalyticsDescription": "Teilen Sie anonyme Nutzungsdaten, damit wir verstehen können, wie die App verwendet wird, und Verbesserungen priorisieren können.",
"embedChapters": "Kapitel einbetten",
"embedChaptersDescription": "Kapitelmarken zur Datei hinzufügen, wenn verfügbar",
"embedMetadata": "Metadaten einbetten",
"embedMetadataDescription": "Titel, Künstler und andere Metadaten schreiben, wenn verfügbar",
"embedSubs": "Untertitel einbetten",
"embedSubsDescription": "Untertitel in die Videodatei einbetten (mp4, webm, mkv)",
"embedThumbnail": "Vorschaubild einbetten",
"embedThumbnailDescription": "Vorschaubild als Covergrafik hinzufügen",
"maxConcurrentDownloads": "Maximale Anzahl aktiver Downloads",
"maxConcurrentDownloadsDescription": "Maximale Anzahl gleichzeitiger Downloads",
"none": "Keine",
@@ -371,8 +452,7 @@
"showMoreFormats": "Mehr Formatoptionen anzeigen",
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
"subscriptionDefaults": {
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.",
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt."
},
"system": "System",
"theme": "Design",
@@ -393,7 +473,6 @@
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
"downloadDirectory": "Download-Verzeichnis",
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
"checkInterval": "Prüfintervall (Stunden)",
"onlyLatest": "Nur das neueste Video herunterladen",
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
},
@@ -468,7 +547,8 @@
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden."
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden.",
"duplicateUrl": "Dieser RSS-Feed ist bereits abonniert."
},
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
"detecting": "Feed wird erkannt...",

View File

@@ -15,7 +15,7 @@
"autoUpdateTitle": "Auto updates",
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
"description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
"followAuthorActions": {
"follow": "Follow @nexmoex"
},
@@ -50,7 +50,13 @@
"documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.",
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Report bugs or request features on GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.",
"discord": "Discord",
"discordDescription": "Join our Discord community for discussions and support.",
"license": "License",
"licenseDescription": "Review the open-source license terms.",
"website": "Official website",
@@ -83,6 +89,7 @@
"currentLocation": "Current download location - ",
"downloadLocation": "Download location",
"downloadSubs": "Download subtitles if available",
"downloadSubsHint": "Save subtitles as separate files when available",
"end": "End",
"endHint": "If kept empty, it will be downloaded to the end",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Download videos and audios from hundreds of sites",
"title": "VidBee"
},
"audioExtract": {
"bad": "Bad",
"best": "Best",
"extract": "Extract",
"good": "Good",
"normal": "Normal",
"selectFormat": "Select Format",
"selectQuality": "Select Quality",
"title": "Extract Audio",
"worst": "Worst"
},
"download": {
"active": "Active",
"all": "All",
@@ -134,6 +130,10 @@
"error": "Error",
"fetch": "Fetch",
"fetchingVideoInfo": "Fetching video info...",
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "This GitHub link is very long. If it fails to open, please open the issue page and paste the logs manually."
},
"history": "History",
"imageLoadError": "Image failed to load",
"imagePlaceholder": "No image available",
@@ -153,6 +153,7 @@
"paste": "Paste",
"pastePlaylistUrl": "Click to paste playlist link from clipboard [Ctrl + V]",
"pasteUrl": "Click to paste video URL or ID [Ctrl + V]",
"pasteUrlButton": "Paste URL",
"preparing": "Preparing...",
"processing": "Processing",
"progress": "Progress",
@@ -200,6 +201,25 @@
"subscription": "Subscription"
}
},
"error": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try reloading the application or report this issue if it persists.",
"message": "Error Message",
"unknownError": "Unknown error occurred",
"goHome": "Go Home",
"reload": "Reload App",
"copyReport": "Copy Error Report",
"copied": "Copied!",
"copySuccess": "Error report copied to clipboard",
"copyFailed": "Failed to copy error report",
"showDetails": "Show Details",
"hideDetails": "Hide Details",
"stackTrace": "Stack Trace",
"componentStack": "Component Stack",
"noStackTrace": "No stack trace available",
"fullReport": "Full Error Report",
"helpText": "If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page."
},
"errors": {
"clickToCopy": "Click to copy details",
"clipboardEmpty": "Clipboard is empty",
@@ -208,6 +228,7 @@
"emptyUrl": "Please enter a URL",
"errorDetails": "Error Details",
"fetchInfoFailed": "Failed to fetch video information",
"invalidUrl": "The clipboard content is not a valid URL",
"networkError": "Some error has occurred. Check your network and use correct URL",
"pasteFromClipboard": "Failed to paste from clipboard"
},
@@ -336,6 +357,8 @@
"selectedVideos": "{{count}} selected",
"downloadCurrentRange": "Download Selected",
"showingCount": "Showing {{count}} videos",
"selectEntry": "Select entry {{index}}",
"noEntriesSelected": "No entries selected",
"startIndex": "Start (1)",
"title": "Download Playlist",
"totalVideos": "Total videos: {{count}}",
@@ -347,7 +370,16 @@
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
"browserForCookiesWindowsNote": "Windows only supports Firefox cookies. For other browsers, please configure a cookies file manually.",
"browserForCookiesProfile": "Profile name or path",
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
"browserForCookiesProfileInvalid": "Profile path is not valid. Choose the profile folder for the selected browser.",
"browserForCookiesProfileInvalidPath": "That folder does not exist. Pick an existing profile folder.",
"browserForCookiesProfileInvalidProfile": "Profile name not found in the default browser location.",
"browserForCookiesProfileInvalidUnsupported": "No default profile location is known for this browser on this platform.",
"browserForCookiesProfileInvalidEmpty": "Enter a profile path for the selected browser.",
"cookiesFile": "Cookies file",
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
"clearCookiesFile": "Clear",
@@ -362,7 +394,10 @@
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Use configuration file",
"configFileDescription": "Custom configuration file for yt-dlp",
@@ -375,6 +410,7 @@
"fileSelectError": "Failed to select file",
"general": "General",
"language": "Language",
"languageDescription": "Choose your preferred language for the application interface",
"light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
@@ -383,6 +419,14 @@
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
"enableAnalytics": "Help improve VidBee",
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
"embedChapters": "Embed chapters",
"embedChaptersDescription": "Add chapter markers to the file when available",
"embedMetadata": "Embed metadata",
"embedMetadataDescription": "Write title, artist, and other metadata when available",
"embedSubs": "Embed subtitles",
"embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)",
"embedThumbnail": "Embed thumbnail",
"embedThumbnailDescription": "Add the thumbnail as cover art",
"maxConcurrentDownloads": "Maximum number of active downloads",
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
"none": "None",
@@ -408,8 +452,7 @@
"showMoreFormats": "Show more format options",
"showMoreFormatsDescription": "Display additional format options in the interface",
"subscriptionDefaults": {
"filenameDescription": "Pattern used when a subscription does not override its filename.",
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
"filenameDescription": "Pattern used when a subscription does not override its filename."
},
"system": "System",
"theme": "Theme",
@@ -430,7 +473,6 @@
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
"downloadDirectory": "Download directory",
"filenameTemplate": "Filename template",
"checkInterval": "Check interval (hours)",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
},
@@ -498,6 +540,7 @@
"missingUrl": "Please paste a channel link first.",
"created": "Subscription added",
"createError": "Failed to add subscription.",
"duplicateUrl": "This RSS feed is already subscribed.",
"refreshStarted": "Refresh started",
"removed": "Subscription removed",
"updated": "Subscription updated",

View File

@@ -51,6 +51,12 @@
"documentationDescription": "Guías, preguntas frecuentes y flujos de trabajo comunes.",
"feedback": "Comentarios e incidencias",
"feedbackDescription": "Comparte ideas o reporta problemas en GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Reportar errores o solicitar funciones en GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Comparte comentarios o sugerencias en X mencionando a @nexmoex.",
"discord": "Discord",
"discordDescription": "Únete a nuestra comunidad de Discord para debates y soporte.",
"license": "Licencia",
"licenseDescription": "Revisa los términos de la licencia de código abierto.",
"website": "Sitio web oficial",
@@ -83,6 +89,7 @@
"currentLocation": "Ubicación de descarga actual - ",
"downloadLocation": "Ubicación de descarga",
"downloadSubs": "Descargar subtítulos si están disponibles",
"downloadSubsHint": "Guardar subtítulos como archivos separados cuando estén disponibles",
"end": "Fin",
"endHint": "Si se deja vacío, se descargará hasta el final",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Descarga videos y audios de cientos de sitios",
"title": "VidBee"
},
"audioExtract": {
"bad": "Malo",
"best": "Mejor",
"extract": "Extraer",
"good": "Bueno",
"normal": "Normal",
"selectFormat": "Seleccionar Formato",
"selectQuality": "Seleccionar Calidad",
"title": "Extraer Audio",
"worst": "Peor"
},
"download": {
"active": "Activo",
"all": "Todo",
@@ -123,6 +119,10 @@
"downloadBtn": "Descargar",
"downloadPending": "Pendiente",
"downloadQueue": "Cola de Descarga",
"customDownloadFolder": "Carpeta de descarga personalizada",
"autoFolderPlaceholder": "Carpeta automática (basada en metadatos)",
"autoFolderHint": "Las carpetas automáticas se crean a partir de metadatos.",
"useAutoFolder": "Usar carpeta automática",
"downloadVideo": "Descargar Video",
"downloading": "Descargando...",
"enterUrl": "Ingresar URL del Video",
@@ -149,15 +149,17 @@
"paste": "Pegar",
"pastePlaylistUrl": "Haz clic para pegar el enlace de la lista de reproducción desde el portapapeles [Ctrl + V]",
"pasteUrl": "Haz clic para pegar la URL o ID del video [Ctrl + V]",
"pasteUrlButton": "Pegar URL",
"preparing": "Preparando...",
"processing": "Procesando",
"progress": "Progreso",
"showDetails": "Mostrar detalles",
"hideDetails": "Ocultar detalles",
"selectAudioFormat": "Seleccionar Formato de Audio",
"selectDownloadType": "Seleccionar tipo de descarga",
"selectFormat": "Seleccionar Formato",
"selectVideoFormat": "Seleccionar Formato de Video",
"startDownload": "Iniciar descarga",
"selectVideoFormat": "Seleccionar Formato de Video",
"singleVideo": "Video Individual",
"speed": "Velocidad",
"title": "Título",
@@ -193,8 +195,31 @@
"formatNote": "Nota de formato",
"protocol": "Protocolo",
"subscription": "Suscripción"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "Este enlace de GitHub es muy largo. Si no se abre, abre la página de la incidencia y pega los registros manualmente."
}
},
"error": {
"title": "Algo salió mal",
"description": "Ocurrió un error inesperado. Intenta recargar la aplicación o informa de este problema si persiste.",
"message": "Mensaje de error",
"unknownError": "Ocurrió un error desconocido",
"goHome": "Ir a inicio",
"reload": "Recargar aplicación",
"copyReport": "Copiar informe de error",
"copied": "¡Copiado!",
"copySuccess": "Informe de error copiado al portapapeles",
"copyFailed": "No se pudo copiar el informe de error",
"showDetails": "Mostrar detalles",
"hideDetails": "Ocultar detalles",
"stackTrace": "Rastro de pila",
"componentStack": "Pila de componentes",
"noStackTrace": "No hay rastro de pila disponible",
"fullReport": "Informe de error completo",
"helpText": "Si este error persiste, copia el informe de error anterior y compártelo con el equipo de soporte. Puedes encontrar la información de contacto en la página Acerca de."
},
"errors": {
"clickToCopy": "Haz clic para copiar los detalles",
"clipboardEmpty": "El portapapeles está vacío",
@@ -203,6 +228,7 @@
"emptyUrl": "Por favor, ingresa una URL",
"errorDetails": "Detalles del Error",
"fetchInfoFailed": "Error al obtener información del video",
"invalidUrl": "El contenido del portapapeles no es una URL válida",
"networkError": "Ha ocurrido un error. Verifica tu red y usa una URL correcta",
"pasteFromClipboard": "Error al pegar desde el portapapeles"
},
@@ -210,10 +236,23 @@
"clearCancelled": "Limpiar Cancelados",
"clearCompleted": "Limpiar Completados",
"clearErrors": "Limpiar Errores",
"clearAll": "Borrar todo el historial",
"clearAllAction": "Borrar historial",
"clearSelection": "Borrar selección",
"confirmClearAllTitle": "¿Borrar todo el historial?",
"confirmClearAllDescription": "Eliminar {{count}} elementos de tu historial. Los archivos permanecen en el disco.",
"confirmDeleteSelectedTitle": "¿Eliminar elementos seleccionados?",
"confirmDeleteSelectedDescription": "Eliminar {{count}} elementos de tu historial. Los archivos permanecen en el disco.",
"alsoDeleteFiles": "También eliminar archivos",
"confirmDeletePlaylistTitle": "¿Eliminar historial de la lista de reproducción?",
"confirmDeletePlaylistDescription": "Eliminar {{count}} elementos de {{title}} y borrar sus archivos.",
"copyToClipboard": "Copiar al portapapeles",
"copyUrl": "Copiar URL",
"date": "Fecha",
"deletePlaylist": "Eliminar lista de reproducción",
"deleteSelected": "Eliminar seleccionados",
"description": "Ver y gestionar tu historial de descargas",
"doneSelecting": "Listo",
"duration": "Duración",
"fileSize": "Tamaño del Archivo",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "Abrir Ubicación del Archivo",
"openFolder": "Abrir Carpeta",
"openInBrowser": "Haz clic para abrir en el navegador",
"removeAction": "Eliminar",
"removeItem": "Eliminar Elemento",
"select": "Seleccionar",
"selectAll": "Seleccionar todo",
"selectVisible": "Seleccionar visibles",
"selectItem": "Seleccionar elemento",
"selectedCount": "{{count}} seleccionados",
"selectionSummary": "{{selected}} de {{total}} visibles seleccionados",
"stats": {
"cancelled": "Cancelado",
"completed": "Completado",
@@ -258,9 +304,15 @@
"downloadCompleted": "Descarga completada",
"downloadFailed": "Error en la descarga",
"downloadStarted": "Descarga iniciada",
"historyCleared": "Historial borrado",
"historyClearFailed": "No se pudo borrar el historial",
"itemRemoved": "Elemento eliminado",
"itemsRemoved": "Se eliminaron {{count}} elementos",
"itemsRemoveFailed": "No se pudieron eliminar los elementos seleccionados",
"openFileFailed": "Error al abrir el archivo",
"openFolderFailed": "Error al abrir la carpeta",
"playlistHistoryRemoved": "Lista de reproducción eliminada y archivos borrados",
"playlistHistoryRemoveFailed": "No se pudo eliminar el historial de la lista de reproducción",
"removeFailed": "Error al eliminar el elemento",
"settingsSaved": "Configuración guardada",
"urlCopied": "URL copiada al portapapeles",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "Lista de reproducción",
"clearPreview": "Limpiar vista previa",
"collapsedProgress": "Descargando lista de reproducción: {{completed}} / {{total}} completado",
"comingSoon": "¡La función de descarga de listas de reproducción llegará pronto!",
"completed": "Lista de reproducción descargada",
"description": "Descarga todos los videos de una lista de reproducción o canal de YouTube",
@@ -284,7 +337,9 @@
"folderFormat": "Formato de nombre de carpeta para listas de reproducción",
"foundVideos": "Se encontraron {{count}} videos en la lista de reproducción",
"groupActive": "{{count}} activo",
"groupCollapse": "Contraer",
"groupErrors": "{{count}} fallido",
"groupExpand": "Expandir",
"groupSummary": "{{completed}} / {{total}} completado",
"linkLabel": "URL de la Lista de Reproducción",
"noEntries": "No se encontraron videos en esta lista de reproducción",
@@ -299,7 +354,11 @@
"range": "Rango (Opcional)",
"resetToDefault": "Restablecer a predeterminado",
"selectedRange": "Rango: {{start}}-{{end}}",
"selectedVideos": "{{count}} seleccionados",
"downloadCurrentRange": "Descargar seleccionados",
"showingCount": "Mostrando {{count}} videos",
"selectEntry": "Seleccionar entrada {{index}}",
"noEntriesSelected": "No hay entradas seleccionadas",
"startIndex": "Inicio (1)",
"title": "Descargar Lista de Reproducción",
"totalVideos": "Total de videos: {{count}}",
@@ -312,6 +371,15 @@
"audio": "Preferencias de Audio",
"browserForCookies": "Seleccionar navegador para usar cookies",
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
"browserForCookiesWindowsNote": "Windows solo admite cookies de Firefox. Para otros navegadores, configura manualmente un archivo de cookies.",
"browserForCookiesProfile": "Nombre de perfil o ruta",
"browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.",
"browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)",
"browserForCookiesProfileInvalid": "La ruta del perfil no es válida. Elige la carpeta de perfil del navegador seleccionado.",
"browserForCookiesProfileInvalidPath": "Esa carpeta no existe. Elige una carpeta de perfil existente.",
"browserForCookiesProfileInvalidProfile": "Nombre de perfil no encontrado en la ubicación predeterminada del navegador.",
"browserForCookiesProfileInvalidUnsupported": "No se conoce una ubicación de perfil predeterminada para este navegador en esta plataforma.",
"browserForCookiesProfileInvalidEmpty": "Introduce una ruta de perfil para el navegador seleccionado.",
"cookiesFile": "Archivo de cookies",
"cookiesFileDescription": "Archivo de cookies con formato Netscape para cargar para autenticación",
"clearCookiesFile": "Limpiar",
@@ -323,9 +391,13 @@
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Usar archivo de configuración",
"configFileDescription": "Archivo de configuración personalizado para yt-dlp",
@@ -338,6 +410,7 @@
"fileSelectError": "Error al seleccionar archivo",
"general": "General",
"language": "Idioma",
"languageDescription": "Elige tu idioma preferido para la interfaz de la aplicación",
"light": "Claro",
"hideDockIcon": "Ocultar icono del Dock",
"hideDockIconDescription": "Eliminar VidBee del Dock de macOS. Usa la barra de menú o el icono de la bandeja para volver a abrir la aplicación.",
@@ -346,6 +419,14 @@
"launchAtLoginUnsupported": "El inicio automático solo está disponible en macOS y Windows.",
"enableAnalytics": "Ayuda a mejorar VidBee",
"enableAnalyticsDescription": "Comparte datos de uso anónimos para ayudarnos a entender cómo se usa la aplicación y priorizar mejoras.",
"embedChapters": "Incrustar capítulos",
"embedChaptersDescription": "Agregar marcadores de capítulos al archivo cuando estén disponibles",
"embedMetadata": "Incrustar metadatos",
"embedMetadataDescription": "Escribir título, artista y otros metadatos cuando estén disponibles",
"embedSubs": "Incrustar subtítulos",
"embedSubsDescription": "Incrustar subtítulos en el archivo de video (mp4, webm, mkv)",
"embedThumbnail": "Incrustar miniatura",
"embedThumbnailDescription": "Agregar la miniatura como portada",
"maxConcurrentDownloads": "Número máximo de descargas activas",
"maxConcurrentDownloadsDescription": "Número máximo de descargas simultáneas",
"none": "Ninguno",
@@ -371,8 +452,7 @@
"showMoreFormats": "Mostrar más opciones de formato",
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
"subscriptionDefaults": {
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.",
"intervalDescription": "Con qué frecuencia VidBee verifica cada feed de suscripción (1-24 horas)."
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo."
},
"system": "Sistema",
"theme": "Tema",
@@ -393,7 +473,6 @@
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
"downloadDirectory": "Directorio de descarga",
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
"checkInterval": "Intervalo de verificación (horas)",
"onlyLatest": "Descargar solo el último video",
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
},
@@ -468,7 +547,8 @@
"itemAlreadyQueued": "Este video ya está en cola",
"queueError": "Error al agregar a la cola de descarga.",
"openLinkError": "Error al abrir el enlace del video.",
"resolveError": "Error al resolver la URL del feed RSS."
"resolveError": "Error al resolver la URL del feed RSS.",
"duplicateUrl": "Este feed RSS ya está suscrito."
},
"detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
"detecting": "Detectando feed...",

View File

@@ -16,7 +16,6 @@
"betaProgramDescription": "Recevez les versions préliminaires et les prochaines fonctionnalités avant tout le monde.",
"betaProgramTitle": "Canal de prévisualisation",
"description": "VidBee est un téléchargeur gratuit et open-source construit avec Electron et alimenté par yt-dlp.",
"downloadingUpdate": "Téléchargement de la mise à jour",
"followAuthorActions": {
"follow": "Suivre @nexmoex"
},
@@ -25,12 +24,6 @@
"followAuthorTitle": "Suivre le Développeur",
"here": "ici",
"homepage": "Page d'accueil",
"latestVersionBadge": "Dernière : v{{version}}",
"latestVersionStatus": {
"available": "Nouvelle version disponible",
"error": "Impossible de récupérer la dernière version",
"uptodate": "Vous êtes à jour"
},
"notifications": {
"checkingUpdates": "Recherche de mises à jour...",
"downloadError": "Échec du téléchargement de la mise à jour",
@@ -38,14 +31,14 @@
"downloadUpdate": "Télécharger et installer la mise à jour {{version}} ?",
"manualDownloadAction": "Téléchargez maintenant",
"noUpdatesAvailable": "Vous utilisez la dernière version",
"restartNowAction": "Redémarrer maintenant",
"restartToUpdate": "Redémarrer maintenant pour installer la mise à jour ?",
"unknownErrorFallback": "Erreur inconnue",
"restartNowAction": "Redémarrer maintenant",
"updateAvailable": "Mise à jour disponible : {{version}}",
"updateAvailableMessage": "Une nouvelle version {{version}} est disponible. \nVeuillez le télécharger sur le site officiel.",
"updateDownloaded": "Mise à jour téléchargée, redémarrez pour installer",
"updateDownloadedVersion": "Mise à jour {{version}} téléchargée, redémarrez pour installer",
"updateError": "Échec de la vérification des mises à jour : {{error}}"
"updateError": "Échec de la vérification des mises à jour : {{error}}",
"unknownErrorFallback": "Erreur inconnue"
},
"preferencesDescription": "Ajustez les paramètres de mise à jour sans quitter cette page.",
"preferencesTitle": "Basculements Rapides",
@@ -58,6 +51,12 @@
"documentationDescription": "Guides, FAQ et flux de travail courants.",
"feedback": "Commentaires et problèmes",
"feedbackDescription": "Partagez des idées ou signalez des problèmes sur GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Signaler des bugs ou demander des fonctionnalités sur GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Partagez des retours ou des suggestions sur X en mentionnant @nexmoex.",
"discord": "Discord",
"discordDescription": "Rejoignez notre communauté Discord pour les discussions et l'assistance.",
"license": "Licence",
"licenseDescription": "Consultez les termes de la licence open-source.",
"website": "Site web officiel",
@@ -76,13 +75,21 @@
"sourceCode": "Le code source est disponible",
"title": "À propos",
"version": "Version",
"versionLabel": "v{{version}}"
"versionLabel": "v{{version}}",
"latestVersionBadge": "Dernière : v{{version}}",
"latestVersionStatus": {
"available": "Nouvelle version disponible",
"uptodate": "Vous êtes à jour",
"error": "Impossible de récupérer la dernière version"
},
"downloadingUpdate": "Téléchargement de la mise à jour"
},
"advancedOptions": {
"closeWhenDone": "Fermer l'application quand le téléchargement se termine",
"currentLocation": "Emplacement de téléchargement actuel - ",
"downloadLocation": "Emplacement de téléchargement",
"downloadSubs": "Télécharger les sous-titres si disponibles",
"downloadSubsHint": "Enregistrer les sous-titres en fichiers séparés lorsqu'ils sont disponibles",
"end": "Fin",
"endHint": "Si laissé vide, sera téléchargé jusqu'à la fin",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Télécharger des vidéos et audios depuis des centaines de sites",
"title": "VidBee"
},
"audioExtract": {
"bad": "Mauvais",
"best": "Meilleur",
"extract": "Extraire",
"good": "Bon",
"normal": "Normal",
"selectFormat": "Sélectionner le Format",
"selectQuality": "Sélectionner la Qualité",
"title": "Extraire l'Audio",
"worst": "Pire"
},
"download": {
"active": "Actif",
"all": "Tout",
@@ -123,6 +119,10 @@
"downloadBtn": "Télécharger",
"downloadPending": "En attente",
"downloadQueue": "File de Téléchargement",
"customDownloadFolder": "Dossier de téléchargement personnalisé",
"autoFolderPlaceholder": "Dossier automatique (basé sur les métadonnées)",
"autoFolderHint": "Les dossiers automatiques sont créés à partir des métadonnées.",
"useAutoFolder": "Utiliser le dossier automatique",
"downloadVideo": "Télécharger la Vidéo",
"downloading": "Téléchargement en cours...",
"enterUrl": "Entrer l'URL de la Vidéo",
@@ -130,44 +130,17 @@
"error": "Erreur",
"fetch": "Récupérer",
"fetchingVideoInfo": "Récupération des informations vidéo...",
"goToSettings": "Allez dans Paramètres",
"hideDetails": "Masquer les détails",
"history": "Historique",
"imageLoadError": "Échec du chargement de l'image",
"imagePlaceholder": "Aucune image disponible",
"infoUnavailable": "Téléchargement en Un Clic (Info indisponible)",
"loading": "Chargement",
"metadata": {
"audioCodec": "Codec audio",
"codec": "Codec",
"completedAt": "Terminé à",
"createdAt": "Créé à",
"description": "Description",
"downloadPath": "Chemin de téléchargement",
"fileSize": "Taille du fichier",
"format": "Format",
"formatNote": "Remarque sur le format",
"fps": "FPS",
"height": "Hauteur",
"playlist": "Liste de lecture",
"protocol": "Protocole",
"quality": "Qualité",
"savedFile": "Fichier enregistré",
"source": "Source",
"speed": "Vitesse",
"startedAt": "Commencé à",
"subscription": "Abonnement",
"tags": "Balises",
"url": "URL source",
"videoCodec": "Codec vidéo",
"views": "Vues",
"width": "Largeur"
},
"moreOptions": "Plus d'options",
"noActiveDownloads": "Aucun téléchargement actif",
"noAudio": "Pas d'Audio",
"noHistory": "Aucun historique de téléchargement",
"noItems": "Aucun élément trouvé",
"goToSettings": "Allez dans Paramètres",
"oneClickDownload": "Téléchargement en Un Clic",
"oneClickDownloadDescription": "Télécharger directement avec les paramètres par défaut sans confirmation",
"oneClickDownloadEnabled": "Le téléchargement en un clic est activé. \nLes téléchargements démarreront directement avec les paramètres par défaut.",
@@ -176,14 +149,17 @@
"paste": "Coller",
"pastePlaylistUrl": "Cliquez pour coller le lien de la playlist depuis le presse-papiers [Ctrl + V]",
"pasteUrl": "Cliquez pour coller l'URL de la vidéo ou l'ID [Ctrl + V]",
"pasteUrlButton": "Coller l'URL",
"preparing": "Préparation...",
"processing": "Traitement",
"progress": "Progrès",
"selectAudioFormat": "Sélectionner le Format Audio",
"selectFormat": "Sélectionner le Format",
"selectVideoFormat": "Sélectionner le Format Vidéo",
"startDownload": "Démarrer le téléchargement",
"showDetails": "Afficher les détails",
"hideDetails": "Masquer les détails",
"selectAudioFormat": "Sélectionner le Format Audio",
"selectDownloadType": "Sélectionner le type de téléchargement",
"selectFormat": "Sélectionner le Format",
"startDownload": "Démarrer le téléchargement",
"selectVideoFormat": "Sélectionner le Format Vidéo",
"singleVideo": "Vidéo Unique",
"speed": "Vitesse",
"title": "Titre",
@@ -193,7 +169,56 @@
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Vidéo",
"videoInfo": "Informations Vidéo",
"videoInfoUpdated": "Informations vidéo mises à jour"
"videoInfoUpdated": "Informations vidéo mises à jour",
"metadata": {
"source": "Source",
"playlist": "Liste de lecture",
"format": "Format",
"quality": "Qualité",
"codec": "Codec",
"savedFile": "Fichier enregistré",
"url": "URL source",
"description": "Description",
"views": "Vues",
"tags": "Balises",
"downloadPath": "Chemin de téléchargement",
"createdAt": "Créé à",
"startedAt": "Commencé à",
"completedAt": "Terminé à",
"speed": "Vitesse",
"fileSize": "Taille du fichier",
"width": "Largeur",
"height": "Hauteur",
"fps": "FPS",
"videoCodec": "Codec vidéo",
"audioCodec": "Codec audio",
"formatNote": "Remarque sur le format",
"protocol": "Protocole",
"subscription": "Abonnement"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "Ce lien GitHub est très long. S'il ne s'ouvre pas, ouvrez la page de l'issue et collez les logs manuellement."
}
},
"error": {
"title": "Un problème est survenu",
"description": "Une erreur inattendue s'est produite. Veuillez recharger l'application ou signaler ce problème s'il persiste.",
"message": "Message d'erreur",
"unknownError": "Une erreur inconnue s'est produite",
"goHome": "Aller à l'accueil",
"reload": "Recharger l'application",
"copyReport": "Copier le rapport d'erreur",
"copied": "Copié !",
"copySuccess": "Rapport d'erreur copié dans le presse-papiers",
"copyFailed": "Échec de la copie du rapport d'erreur",
"showDetails": "Afficher les détails",
"hideDetails": "Masquer les détails",
"stackTrace": "Trace de pile",
"componentStack": "Pile de composants",
"noStackTrace": "Aucune trace de pile disponible",
"fullReport": "Rapport d'erreur complet",
"helpText": "Si cette erreur persiste, veuillez copier le rapport d'erreur ci-dessus et le partager avec l'équipe de support. Vous trouverez les coordonnées sur la page À propos."
},
"errors": {
"clickToCopy": "Cliquez pour copier les détails",
@@ -203,6 +228,7 @@
"emptyUrl": "Veuillez entrer une URL",
"errorDetails": "Détails de l'Erreur",
"fetchInfoFailed": "Échec de la récupération des informations vidéo",
"invalidUrl": "Le contenu du presse-papiers n'est pas une URL valide",
"networkError": "Une erreur s'est produite. Vérifiez votre réseau et utilisez une URL correcte",
"pasteFromClipboard": "Échec du collage depuis le presse-papiers"
},
@@ -210,10 +236,23 @@
"clearCancelled": "Effacer les Annulés",
"clearCompleted": "Effacer les Terminés",
"clearErrors": "Effacer les Erreurs",
"clearAll": "Effacer tout l'historique",
"clearAllAction": "Effacer l'historique",
"clearSelection": "Effacer la sélection",
"confirmClearAllTitle": "Effacer tout l'historique ?",
"confirmClearAllDescription": "Supprimer {{count}} éléments de votre historique. Les fichiers restent sur le disque.",
"confirmDeleteSelectedTitle": "Supprimer les éléments sélectionnés ?",
"confirmDeleteSelectedDescription": "Supprimer {{count}} éléments de votre historique. Les fichiers restent sur le disque.",
"alsoDeleteFiles": "Supprimer aussi les fichiers",
"confirmDeletePlaylistTitle": "Supprimer l'historique de la playlist ?",
"confirmDeletePlaylistDescription": "Supprimer {{count}} éléments de {{title}} et supprimer leurs fichiers.",
"copyToClipboard": "Copier dans le presse-papiers",
"copyUrl": "Copier l'URL",
"date": "Date",
"deletePlaylist": "Supprimer la playlist",
"deleteSelected": "Supprimer la sélection",
"description": "Voir et gérer votre historique de téléchargements",
"doneSelecting": "Terminé",
"duration": "Durée",
"fileSize": "Taille du Fichier",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "Ouvrir l'Emplacement du Fichier",
"openFolder": "Ouvrir le Dossier",
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
"removeAction": "Supprimer",
"removeItem": "Supprimer l'Élément",
"select": "Sélectionner",
"selectAll": "Tout sélectionner",
"selectVisible": "Sélectionner les visibles",
"selectItem": "Sélectionner l'élément",
"selectedCount": "{{count}} sélectionnés",
"selectionSummary": "{{selected}} sur {{total}} visibles sélectionnés",
"stats": {
"cancelled": "Annulé",
"completed": "Terminé",
@@ -247,9 +293,9 @@
"about": "À propos",
"download": "Télécharger",
"playlist": "Télécharger la Playlist",
"preferences": "Préférences",
"rss": "RSS",
"subscriptions": "Abonnements",
"preferences": "Préférences",
"supportedSites": "Sites Supportés",
"theme": "Thème :"
},
@@ -258,9 +304,15 @@
"downloadCompleted": "Téléchargement terminé",
"downloadFailed": "Échec du téléchargement",
"downloadStarted": "Téléchargement démarré",
"historyCleared": "Historique effacé",
"historyClearFailed": "Échec de l'effacement de l'historique",
"itemRemoved": "Élément supprimé",
"itemsRemoved": "{{count}} éléments supprimés",
"itemsRemoveFailed": "Échec de la suppression des éléments sélectionnés",
"openFileFailed": "Échec de l'ouverture du fichier",
"openFolderFailed": "Échec de l'ouverture du dossier",
"playlistHistoryRemoved": "Playlist supprimée et fichiers supprimés",
"playlistHistoryRemoveFailed": "Échec de la suppression de l'historique de la playlist",
"removeFailed": "Échec de la suppression de l'élément",
"settingsSaved": "Paramètres sauvegardés",
"urlCopied": "URL copiée dans le presse-papiers",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "Liste de lecture",
"clearPreview": "Effacer l'aperçu",
"collapsedProgress": "Téléchargement de la playlist : {{completed}} / {{total}} terminés",
"comingSoon": "La fonctionnalité de téléchargement de playlist arrive bientôt !",
"completed": "Playlist téléchargée",
"description": "Télécharger toutes les vidéos d'une playlist ou chaîne YouTube",
@@ -284,7 +337,9 @@
"folderFormat": "Format de nom de dossier pour les playlists",
"foundVideos": "Trouvé {{count}} vidéos dans la playlist",
"groupActive": "{{count}} actifs",
"groupCollapse": "Réduire",
"groupErrors": "{{count}} a échoué",
"groupExpand": "Développer",
"groupSummary": "{{completed}} / {{total}} terminé",
"linkLabel": "URL de la Playlist",
"noEntries": "Aucune vidéo n'a été trouvée dans cette playlist",
@@ -294,12 +349,16 @@
"positionLabel": "Article {{index}} sur {{total}}",
"previewButton": "Aperçu de la liste de lecture",
"previewFailed": "Échec de la prévisualisation de la playlist",
"previewRequired": "Prévisualisez la playlist avant de la télécharger.",
"previewSummary": "Prévisualisez les éléments de la liste de lecture avant de les télécharger.",
"previewRequired": "Prévisualisez la playlist avant de la télécharger.",
"range": "Plage (Optionnel)",
"resetToDefault": "Réinitialiser par défaut",
"selectedRange": "Plage : {{start}}-{{end}}",
"selectedVideos": "{{count}} sélectionnés",
"downloadCurrentRange": "Télécharger la sélection",
"showingCount": "Affichage de {{count}} vidéos",
"selectEntry": "Sélectionner l'entrée {{index}}",
"noEntriesSelected": "Aucune entrée sélectionnée",
"startIndex": "Début (1)",
"title": "Télécharger la Playlist",
"totalVideos": "Nombre total de vidéos : {{count}}",
@@ -312,39 +371,62 @@
"audio": "Préférences Audio",
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
"browserForCookiesWindowsNote": "Sous Windows, seuls les cookies de Firefox sont pris en charge. Pour les autres navigateurs, configurez un fichier de cookies manuellement.",
"browserForCookiesProfile": "Nom du profil ou chemin",
"browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.",
"browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)",
"browserForCookiesProfileInvalid": "Le chemin du profil n'est pas valide. Choisissez le dossier de profil du navigateur sélectionné.",
"browserForCookiesProfileInvalidPath": "Ce dossier n'existe pas. Choisissez un dossier de profil existant.",
"browserForCookiesProfileInvalidProfile": "Nom de profil introuvable à l'emplacement par défaut du navigateur.",
"browserForCookiesProfileInvalidUnsupported": "Aucun emplacement de profil par défaut n'est connu pour ce navigateur sur cette plateforme.",
"browserForCookiesProfileInvalidEmpty": "Saisissez un chemin de profil pour le navigateur sélectionné.",
"cookiesFile": "Fichier de cookies",
"cookiesFileDescription": "Fichier de cookies au format Netscape à charger pour l'authentification",
"clearCookiesFile": "Clair",
"cookiesHelpTitle": "Utiliser des cookies",
"cookiesHelpBrowser": "Choisissez votre navigateur ci-dessus pour réutiliser automatiquement sa session de connexion.",
"cookiesHelpFile": "Exportez un fichier de cookies Netscape (voir la FAQ yt-dlp) et sélectionnez-le ici si nécessaire.",
"cookiesHelpFaq": "Ouvrir la FAQ sur les cookies yt-dlp",
"openLinkError": "Échec de l'ouverture du lien",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"clearConfigFile": "Clair",
"clearCookiesFile": "Clair",
"configFile": "Utiliser le fichier de configuration",
"configFileDescription": "Fichier de configuration personnalisé pour yt-dlp",
"cookiesFile": "Fichier de cookies",
"cookiesFileDescription": "Fichier de cookies au format Netscape à charger pour l'authentification",
"cookiesHelpBrowser": "Choisissez votre navigateur ci-dessus pour réutiliser automatiquement sa session de connexion.",
"cookiesHelpFaq": "Ouvrir la FAQ sur les cookies yt-dlp",
"cookiesHelpFile": "Exportez un fichier de cookies Netscape (voir la FAQ yt-dlp) et sélectionnez-le ici si nécessaire.",
"cookiesHelpTitle": "Utiliser des cookies",
"clearConfigFile": "Clair",
"dark": "Sombre",
"description": "Configurez vos préférences de téléchargement et paramètres de l'application",
"directorySelectError": "Échec de la sélection du répertoire",
"downloadPath": "Emplacement de téléchargement",
"downloadPathDescription": "Choisissez où sauvegarder les fichiers téléchargés",
"enableAnalytics": "Aidez-nous à améliorer VidBee",
"enableAnalyticsDescription": "Partagez des données d'utilisation anonymes pour nous aider à comprendre comment l'application est utilisée et prioriser les améliorations.",
"fileSelectError": "Échec de la sélection du fichier",
"general": "Général",
"language": "Langue",
"languageDescription": "Choisissez votre langue préférée pour l'interface de l'application",
"light": "Clair",
"hideDockIcon": "Masquer l'icône du Dock",
"hideDockIconDescription": "Supprimez VidBee du Dock macOS. \nUtilisez la barre de menu ou l'icône de la barre d'état pour rouvrir l'application.",
"language": "Langue",
"launchAtLogin": "Lancer au démarrage",
"launchAtLoginDescription": "Ouvrez VidBee automatiquement après vous être connecté à votre ordinateur.",
"launchAtLoginUnsupported": "Le lancement automatique n'est disponible que sur macOS et Windows.",
"light": "Clair",
"enableAnalytics": "Aidez-nous à améliorer VidBee",
"enableAnalyticsDescription": "Partagez des données d'utilisation anonymes pour nous aider à comprendre comment l'application est utilisée et prioriser les améliorations.",
"embedChapters": "Intégrer les chapitres",
"embedChaptersDescription": "Ajouter des marqueurs de chapitre au fichier lorsqu'ils sont disponibles",
"embedMetadata": "Intégrer les métadonnées",
"embedMetadataDescription": "Écrire le titre, l'artiste et d'autres métadonnées lorsqu'ils sont disponibles",
"embedSubs": "Intégrer les sous-titres",
"embedSubsDescription": "Intégrer les sous-titres dans le fichier vidéo (mp4, webm, mkv)",
"embedThumbnail": "Intégrer la miniature",
"embedThumbnailDescription": "Ajouter la miniature comme illustration de couverture",
"maxConcurrentDownloads": "Nombre maximum de téléchargements actifs",
"maxConcurrentDownloadsDescription": "Nombre maximum de téléchargements simultanés",
"none": "Aucun",
@@ -362,7 +444,6 @@
"normal": "Normal",
"worst": "Pire"
},
"openLinkError": "Échec de l'ouverture du lien",
"proxy": "Proxy",
"proxyDescription": "Serveur proxy pour les requêtes réseau",
"proxyPlaceholder": "http://proxy:port",
@@ -371,8 +452,7 @@
"showMoreFormats": "Afficher plus d'options de format",
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
"subscriptionDefaults": {
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier.",
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier."
},
"system": "Système",
"theme": "Thème",
@@ -384,6 +464,120 @@
},
"video": "Préférences Vidéo"
},
"subscriptions": {
"title": "Abonnements",
"subtitle": "{{count}} abonnement{{count, plural, one {} other {s}}}",
"description": "Surveillez automatiquement les flux RSS et mettez les nouveaux téléchargements en file dattente sans travail manuel.",
"defaults": {
"title": "Paramètres par défaut de l'automatisation",
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
"downloadDirectory": "Répertoire de téléchargement",
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Lorsqu'il est activé, VidBee ignore les anciens éléments du backlog et récupère uniquement le téléchargement le plus récent."
},
"add": {
"title": "Ajouter un flux RSS",
"description": "Collez un lien de flux RSS. \nVidBee détectera automatiquement le flux."
},
"fields": {
"url": "URL du flux",
"keywords": "Filtre de mots clés (séparés par des virgules)",
"tags": "Balises automatiques",
"customDirectory": "Répertoire personnalisé",
"namingTemplate": "Modèle de nom de fichier personnalisé (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Ignorez les éléments du backlog et récupérez uniquement le téléchargement le plus récent à partir de ce flux.",
"enabled": "Activé",
"disabled": "Désactivé",
"onlyLatestShort": "Seulement le dernier"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Ajouter",
"refresh": "Rafraîchir",
"edit": "Modifier",
"remove": "Retirer",
"save": "Enregistrer les modifications",
"selectDirectory": "Parcourir",
"enable": "Activer",
"disable": "Désactiver"
},
"items": {
"title": "Derniers téléchargements ({{count}})",
"count": "{{count}} articles",
"empty": "Aucun élément de flux récent trouvé.",
"status": {
"queued": "En file d'attente",
"notQueued": "Pas en file d'attente",
"pending": "En attente",
"downloading": "Téléchargement",
"processing": "Traitement",
"completed": "Complété",
"error": "Échoué",
"cancelled": "Annulé"
},
"fromChannel": "De {{channel}}",
"tooltip": {
"downloadStatus": "État du téléchargement : {{status}}",
"downloadPending": "En attente des détails du téléchargement...",
"notQueued": "Pas encore dans la file d'attente de téléchargement"
},
"actions": {
"open": "Ouvrir dans le navigateur",
"queue": "Ajouter à la file d'attente de téléchargement"
}
},
"labels": {
"subscription": "Abonnement",
"unknown": "Abonnement inconnu",
"noThumbnail": "Aucune vignette"
},
"notifications": {
"directoryError": "Échec de l'ouverture du sélecteur de répertoire.",
"missingUrl": "Veuillez d'abord coller un lien de chaîne.",
"created": "Abonnement ajouté",
"createError": "Échec de l'ajout de l'abonnement.",
"refreshStarted": "Actualisation démarrée",
"removed": "Abonnement supprimé",
"updated": "Abonnement mis à jour",
"itemQueued": "Ajouté à la file d'attente de téléchargement",
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
"openLinkError": "Échec de l'ouverture du lien vidéo.",
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
"duplicateUrl": "Ce flux RSS est déjà abonné."
},
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
"detecting": "Détection du flux...",
"latestVideo": "Dernière vidéo : {{title}}",
"lastChecked": "Dernière vérification : {{time}}",
"never": "Jamais",
"empty": "Aucun abonnement pour l'instant. \nAjoutez vos chaînes préférées pour lancer le téléchargement automatique.",
"edit": {
"title": "Modifier {{name}}",
"description": "Ajustez les filtres, les balises et les remplacements pour ce flux."
},
"status": {
"title": "Statut",
"up-to-date": "À jour",
"checking": "Vérification",
"failed": "Échoué",
"idle": "Inactif",
"tooltip": {
"updatedAt": "Mise à jour : {{time}}"
}
},
"rssHub": {
"title": "Abonnements automatisés avec RSSHub",
"description": "Combinez VidBee avec RSSHub pour activer les abonnements et les téléchargements automatisés à partir de diverses plateformes. \nUne fois configuré, VidBee s'exécute en arrière-plan et télécharge automatiquement les dernières vidéos et contenus.",
"learnMore": "En savoir plus sur RSSHub",
"openDocs": "Ouvrir la documentation RSSHub",
"hint": "Vous n'avez pas d'URL de flux RSS ? \nUtilisez RSSHub pour générer des flux RSS pour YouTube, Twitter et des milliers d'autres plateformes."
}
},
"sites": {
"homeInlineDescription": "Supporte {{sites}} et plus.",
"moreDescription": "La liste complète yt-dlp est mise à jour constamment par la communauté.",
@@ -468,119 +662,5 @@
},
"popularSection": "Plateformes principales",
"viewAll": "Voir tous les sites supportés"
},
"subscriptions": {
"actions": {
"add": "Ajouter",
"disable": "Désactiver",
"edit": "Modifier",
"enable": "Activer",
"refresh": "Rafraîchir",
"remove": "Retirer",
"save": "Enregistrer les modifications",
"selectDirectory": "Parcourir"
},
"add": {
"description": "Collez un lien de flux RSS. \nVidBee détectera automatiquement le flux.",
"title": "Ajouter un flux RSS"
},
"defaults": {
"checkInterval": "Intervalle de vérification (heures)",
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
"downloadDirectory": "Répertoire de téléchargement",
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Lorsqu'il est activé, VidBee ignore les anciens éléments du backlog et récupère uniquement le téléchargement le plus récent.",
"title": "Paramètres par défaut de l'automatisation"
},
"description": "Surveillez automatiquement les flux RSS et mettez les nouveaux téléchargements en file dattente sans travail manuel.",
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
"detecting": "Détection du flux...",
"edit": {
"description": "Ajustez les filtres, les balises et les remplacements pour ce flux.",
"title": "Modifier {{name}}"
},
"empty": "Aucun abonnement pour l'instant. \nAjoutez vos chaînes préférées pour lancer le téléchargement automatique.",
"fields": {
"customDirectory": "Répertoire personnalisé",
"disabled": "Désactivé",
"enabled": "Activé",
"keywords": "Filtre de mots clés (séparés par des virgules)",
"namingTemplate": "Modèle de nom de fichier personnalisé (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Ignorez les éléments du backlog et récupérez uniquement le téléchargement le plus récent à partir de ce flux.",
"onlyLatestShort": "Seulement le dernier",
"tags": "Balises automatiques",
"url": "URL du flux"
},
"items": {
"actions": {
"open": "Ouvrir dans le navigateur",
"queue": "Ajouter à la file d'attente de téléchargement"
},
"count": "{{count}} articles",
"empty": "Aucun élément de flux récent trouvé.",
"fromChannel": "De {{channel}}",
"status": {
"cancelled": "Annulé",
"completed": "Complété",
"downloading": "Téléchargement",
"error": "Échoué",
"notQueued": "Pas en file d'attente",
"pending": "En attente",
"processing": "Traitement",
"queued": "En file d'attente"
},
"title": "Derniers téléchargements ({{count}})",
"tooltip": {
"downloadPending": "En attente des détails du téléchargement...",
"downloadStatus": "État du téléchargement : {{status}}",
"notQueued": "Pas encore dans la file d'attente de téléchargement"
}
},
"labels": {
"noThumbnail": "Aucune vignette",
"subscription": "Abonnement",
"unknown": "Abonnement inconnu"
},
"lastChecked": "Dernière vérification : {{time}}",
"latestVideo": "Dernière vidéo : {{title}}",
"never": "Jamais",
"notifications": {
"createError": "Échec de l'ajout de l'abonnement.",
"created": "Abonnement ajouté",
"directoryError": "Échec de l'ouverture du sélecteur de répertoire.",
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
"itemQueued": "Ajouté à la file d'attente de téléchargement",
"missingUrl": "Veuillez d'abord coller un lien de chaîne.",
"openLinkError": "Échec de l'ouverture du lien vidéo.",
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
"refreshStarted": "Actualisation démarrée",
"removed": "Abonnement supprimé",
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
"updated": "Abonnement mis à jour"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"rssHub": {
"description": "Combinez VidBee avec RSSHub pour activer les abonnements et les téléchargements automatisés à partir de diverses plateformes. \nUne fois configuré, VidBee s'exécute en arrière-plan et télécharge automatiquement les dernières vidéos et contenus.",
"hint": "Vous n'avez pas d'URL de flux RSS ? \nUtilisez RSSHub pour générer des flux RSS pour YouTube, Twitter et des milliers d'autres plateformes.",
"learnMore": "En savoir plus sur RSSHub",
"openDocs": "Ouvrir la documentation RSSHub",
"title": "Abonnements automatisés avec RSSHub"
},
"status": {
"checking": "Vérification",
"failed": "Échoué",
"idle": "Inactif",
"title": "Statut",
"tooltip": {
"updatedAt": "Mise à jour : {{time}}"
},
"up-to-date": "À jour"
},
"subtitle": "{{count}} abonnement{{count, plural, one {} other {s}}}",
"title": "Abonnements"
}
}

View File

@@ -51,6 +51,12 @@
"documentationDescription": "Panduan, FAQ, dan alur kerja umum.",
"feedback": "Masukan & masalah",
"feedbackDescription": "Bagikan ide atau laporkan masalah di GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Laporkan bug atau minta fitur di GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Bagikan masukan atau saran di X dengan menyebut @nexmoex.",
"discord": "Discord",
"discordDescription": "Bergabunglah dengan komunitas Discord kami untuk diskusi dan dukungan.",
"license": "Lisensi",
"licenseDescription": "Tinjau ketentuan lisensi open-source.",
"website": "Situs web resmi",
@@ -83,6 +89,7 @@
"currentLocation": "Lokasi unduhan saat ini - ",
"downloadLocation": "Lokasi unduhan",
"downloadSubs": "Unduh subtitle jika tersedia",
"downloadSubsHint": "Simpan subtitle sebagai file terpisah jika tersedia",
"end": "Akhir",
"endHint": "Jika dibiarkan kosong, akan diunduh sampai akhir",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Unduh video dan audio dari ratusan situs",
"title": "VidBee"
},
"audioExtract": {
"bad": "Buruk",
"best": "Terbaik",
"extract": "Ekstrak",
"good": "Baik",
"normal": "Normal",
"selectFormat": "Pilih Format",
"selectQuality": "Pilih Kualitas",
"title": "Ekstrak Audio",
"worst": "Terburuk"
},
"download": {
"active": "Aktif",
"all": "Semua",
@@ -123,6 +119,10 @@
"downloadBtn": "Unduh",
"downloadPending": "Menunggu",
"downloadQueue": "Antrian Unduhan",
"customDownloadFolder": "Folder unduhan khusus",
"autoFolderPlaceholder": "Folder otomatis (berdasarkan metadata)",
"autoFolderHint": "Folder otomatis dibuat dari metadata.",
"useAutoFolder": "Gunakan folder otomatis",
"downloadVideo": "Unduh Video",
"downloading": "Mengunduh...",
"enterUrl": "Masukkan URL Video",
@@ -149,15 +149,17 @@
"paste": "Tempel",
"pastePlaylistUrl": "Klik untuk menempelkan tautan playlist dari clipboard [Ctrl + V]",
"pasteUrl": "Klik untuk menempelkan URL video atau ID [Ctrl + V]",
"pasteUrlButton": "Tempel URL",
"preparing": "Mempersiapkan...",
"processing": "Memproses",
"progress": "Kemajuan",
"showDetails": "Tampilkan detail",
"hideDetails": "Sembunyikan detail",
"selectAudioFormat": "Pilih Format Audio",
"selectDownloadType": "Pilih jenis unduhan",
"selectFormat": "Pilih Format",
"selectVideoFormat": "Pilih Format Video",
"startDownload": "Mulai unduhan",
"selectVideoFormat": "Pilih Format Video",
"singleVideo": "Video Tunggal",
"speed": "Kecepatan",
"title": "Judul",
@@ -193,8 +195,31 @@
"formatNote": "Catatan format",
"protocol": "Protokol",
"subscription": "Berlangganan"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "Tautan GitHub ini sangat panjang. Jika tidak terbuka, buka halaman issue dan tempel log secara manual."
}
},
"error": {
"title": "Terjadi kesalahan",
"description": "Terjadi kesalahan tak terduga. Silakan muat ulang aplikasi atau laporkan masalah ini jika terus terjadi.",
"message": "Pesan kesalahan",
"unknownError": "Terjadi kesalahan yang tidak diketahui",
"goHome": "Ke beranda",
"reload": "Muat ulang aplikasi",
"copyReport": "Salin laporan kesalahan",
"copied": "Tersalin!",
"copySuccess": "Laporan kesalahan disalin ke papan klip",
"copyFailed": "Gagal menyalin laporan kesalahan",
"showDetails": "Tampilkan detail",
"hideDetails": "Sembunyikan detail",
"stackTrace": "Jejak tumpukan",
"componentStack": "Tumpukan komponen",
"noStackTrace": "Tidak ada jejak tumpukan yang tersedia",
"fullReport": "Laporan kesalahan lengkap",
"helpText": "Jika kesalahan ini terus terjadi, silakan salin laporan kesalahan di atas dan bagikan dengan tim dukungan. Informasi kontak dapat ditemukan di halaman Tentang."
},
"errors": {
"clickToCopy": "Klik untuk menyalin detail",
"clipboardEmpty": "Clipboard kosong",
@@ -203,6 +228,7 @@
"emptyUrl": "Silakan masukkan URL",
"errorDetails": "Detail Kesalahan",
"fetchInfoFailed": "Gagal mengambil informasi video",
"invalidUrl": "Konten papan klip bukan URL yang valid",
"networkError": "Terjadi kesalahan. Periksa jaringan Anda dan gunakan URL yang benar",
"pasteFromClipboard": "Gagal menempel dari clipboard"
},
@@ -210,10 +236,23 @@
"clearCancelled": "Hapus Dibatalkan",
"clearCompleted": "Hapus Selesai",
"clearErrors": "Hapus Kesalahan",
"clearAll": "Hapus semua riwayat",
"clearAllAction": "Hapus riwayat",
"clearSelection": "Hapus pilihan",
"confirmClearAllTitle": "Hapus semua riwayat?",
"confirmClearAllDescription": "Hapus {{count}} item dari riwayat Anda. File tetap di disk.",
"confirmDeleteSelectedTitle": "Hapus item yang dipilih?",
"confirmDeleteSelectedDescription": "Hapus {{count}} item dari riwayat Anda. File tetap di disk.",
"alsoDeleteFiles": "Hapus juga file",
"confirmDeletePlaylistTitle": "Hapus riwayat playlist?",
"confirmDeletePlaylistDescription": "Hapus {{count}} item dari {{title}} dan hapus file-nya.",
"copyToClipboard": "Salin ke clipboard",
"copyUrl": "Salin URL",
"date": "Tanggal",
"deletePlaylist": "Hapus playlist",
"deleteSelected": "Hapus yang dipilih",
"description": "Lihat dan kelola riwayat unduhan Anda",
"doneSelecting": "Selesai",
"duration": "Durasi",
"fileSize": "Ukuran File",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "Buka Lokasi File",
"openFolder": "Buka Folder",
"openInBrowser": "Klik untuk membuka di browser",
"removeAction": "Hapus",
"removeItem": "Hapus Item",
"select": "Pilih",
"selectAll": "Pilih semua",
"selectVisible": "Pilih yang terlihat",
"selectItem": "Pilih item",
"selectedCount": "{{count}} dipilih",
"selectionSummary": "{{selected}} dari {{total}} terlihat dipilih",
"stats": {
"cancelled": "Dibatalkan",
"completed": "Selesai",
@@ -258,9 +304,15 @@
"downloadCompleted": "Unduhan selesai",
"downloadFailed": "Unduhan gagal",
"downloadStarted": "Unduhan dimulai",
"historyCleared": "Riwayat dihapus",
"historyClearFailed": "Gagal menghapus riwayat",
"itemRemoved": "Item dihapus",
"itemsRemoved": "{{count}} item dihapus",
"itemsRemoveFailed": "Gagal menghapus item yang dipilih",
"openFileFailed": "Gagal membuka file",
"openFolderFailed": "Gagal membuka folder",
"playlistHistoryRemoved": "Playlist dihapus dan file dihapus",
"playlistHistoryRemoveFailed": "Gagal menghapus riwayat playlist",
"removeFailed": "Gagal menghapus item",
"settingsSaved": "Pengaturan disimpan",
"urlCopied": "URL disalin ke clipboard",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Hapus pratinjau",
"collapsedProgress": "Mengunduh playlist: {{completed}} / {{total}} selesai",
"comingSoon": "Fitur unduh playlist segera hadir!",
"completed": "Playlist diunduh",
"description": "Unduh semua video dari playlist atau saluran YouTube",
@@ -284,7 +337,9 @@
"folderFormat": "Format nama folder untuk playlist",
"foundVideos": "Ditemukan {{count}} video dalam playlist",
"groupActive": "{{count}} aktif",
"groupCollapse": "Ciutkan",
"groupErrors": "{{count}} gagal",
"groupExpand": "Perluas",
"groupSummary": "{{completed}} / {{total}} selesai",
"linkLabel": "URL Playlist",
"noEntries": "Tidak ada video yang ditemukan dalam playlist ini",
@@ -299,7 +354,11 @@
"range": "Rentang (Opsional)",
"resetToDefault": "Reset ke default",
"selectedRange": "Rentang: {{start}}-{{end}}",
"selectedVideos": "{{count}} dipilih",
"downloadCurrentRange": "Unduh yang dipilih",
"showingCount": "Menampilkan {{count}} video",
"selectEntry": "Pilih entri {{index}}",
"noEntriesSelected": "Tidak ada entri yang dipilih",
"startIndex": "Mulai (1)",
"title": "Unduh Playlist",
"totalVideos": "Total video: {{count}}",
@@ -312,6 +371,15 @@
"audio": "Preferensi Audio",
"browserForCookies": "Pilih browser untuk menggunakan cookie",
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
"browserForCookiesWindowsNote": "Di Windows, hanya cookie Firefox yang didukung. Untuk browser lain, silakan konfigurasi file cookie secara manual.",
"browserForCookiesProfile": "Nama profil atau path",
"browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.",
"browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)",
"browserForCookiesProfileInvalid": "Path profil tidak valid. Pilih folder profil untuk browser yang dipilih.",
"browserForCookiesProfileInvalidPath": "Folder itu tidak ada. Pilih folder profil yang ada.",
"browserForCookiesProfileInvalidProfile": "Nama profil tidak ditemukan di lokasi browser default.",
"browserForCookiesProfileInvalidUnsupported": "Tidak ada lokasi profil default yang diketahui untuk browser ini di platform ini.",
"browserForCookiesProfileInvalidEmpty": "Masukkan path profil untuk browser yang dipilih.",
"cookiesFile": "File cookie",
"cookiesFileDescription": "File cookie format Netscape untuk dimuat untuk autentikasi",
"clearCookiesFile": "Hapus",
@@ -323,9 +391,13 @@
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Gunakan file konfigurasi",
"configFileDescription": "File konfigurasi khusus untuk yt-dlp",
@@ -338,6 +410,7 @@
"fileSelectError": "Gagal memilih file",
"general": "Umum",
"language": "Bahasa",
"languageDescription": "Pilih bahasa pilihan Anda untuk antarmuka aplikasi",
"light": "Terang",
"hideDockIcon": "Sembunyikan ikon Dock",
"hideDockIconDescription": "Hapus VidBee dari Dock macOS. Gunakan menu bar atau ikon tray untuk membuka kembali aplikasi.",
@@ -346,6 +419,14 @@
"launchAtLoginUnsupported": "Peluncuran otomatis hanya tersedia di macOS dan Windows.",
"enableAnalytics": "Bantu tingkatkan VidBee",
"enableAnalyticsDescription": "Bagikan data penggunaan anonim untuk membantu kami memahami bagaimana aplikasi digunakan dan memprioritaskan peningkatan.",
"embedChapters": "Sematkan bab",
"embedChaptersDescription": "Tambahkan penanda bab ke file saat tersedia",
"embedMetadata": "Sematkan metadata",
"embedMetadataDescription": "Tulis judul, artis, dan metadata lain saat tersedia",
"embedSubs": "Sematkan subtitle",
"embedSubsDescription": "Sematkan subtitle ke file video (mp4, webm, mkv)",
"embedThumbnail": "Sematkan thumbnail",
"embedThumbnailDescription": "Tambahkan thumbnail sebagai sampul",
"maxConcurrentDownloads": "Jumlah maksimum unduhan aktif",
"maxConcurrentDownloadsDescription": "Jumlah maksimum unduhan bersamaan",
"none": "Tidak ada",
@@ -371,8 +452,7 @@
"showMoreFormats": "Tampilkan lebih banyak opsi format",
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
"subscriptionDefaults": {
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.",
"intervalDescription": "Seberapa sering VidBee memeriksa setiap feed berlangganan (1-24 jam)."
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya."
},
"system": "Sistem",
"theme": "Tema",
@@ -393,7 +473,6 @@
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
"downloadDirectory": "Direktori unduhan",
"filenameTemplate": "Template nama file (hanya file)",
"checkInterval": "Interval pemeriksaan (jam)",
"onlyLatest": "Unduh hanya video terbaru",
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
},
@@ -468,7 +547,8 @@
"itemAlreadyQueued": "Video ini sudah diantre",
"queueError": "Gagal menambahkan ke antrian unduhan.",
"openLinkError": "Gagal membuka tautan video.",
"resolveError": "Gagal menyelesaikan URL feed RSS."
"resolveError": "Gagal menyelesaikan URL feed RSS.",
"duplicateUrl": "Feed RSS ini sudah berlangganan."
},
"detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
"detecting": "Mendeteksi feed...",

View File

@@ -16,7 +16,6 @@
"betaProgramDescription": "Ricevi build anticipate e prossime funzionalità prima di tutti.",
"betaProgramTitle": "Canale anteprima",
"description": "VidBee è un downloader gratuito e open-source costruito con Electron e alimentato da yt-dlp.",
"downloadingUpdate": "Download dell'aggiornamento",
"followAuthorActions": {
"follow": "Segui @nexmoex"
},
@@ -25,12 +24,6 @@
"followAuthorTitle": "Segui lo Sviluppatore",
"here": "qui",
"homepage": "Homepage",
"latestVersionBadge": "Ultima: v{{version}}",
"latestVersionStatus": {
"available": "Nuova versione disponibile",
"error": "Impossibile recuperare l'ultima versione",
"uptodate": "Sei aggiornato"
},
"notifications": {
"checkingUpdates": "Ricerca aggiornamenti...",
"downloadError": "Errore nel download dell'aggiornamento",
@@ -38,14 +31,14 @@
"downloadUpdate": "Scarica e installa l'aggiornamento {{version}}?",
"manualDownloadAction": "Scarica ora",
"noUpdatesAvailable": "Stai usando l'ultima versione",
"restartNowAction": "Ricomincia adesso",
"restartToUpdate": "Riavvia ora per installare l'aggiornamento?",
"unknownErrorFallback": "Errore sconosciuto",
"restartNowAction": "Ricomincia adesso",
"updateAvailable": "Aggiornamento disponibile: {{version}}",
"updateAvailableMessage": "È disponibile una nuova versione {{version}}. \nSi prega di scaricarlo dal sito ufficiale.",
"updateDownloaded": "Aggiornamento scaricato, riavvia per installare",
"updateDownloadedVersion": "Aggiornamento {{version}} scaricato, riavvia per installare",
"updateError": "Errore nel controllo degli aggiornamenti: {{error}}"
"updateError": "Errore nel controllo degli aggiornamenti: {{error}}",
"unknownErrorFallback": "Errore sconosciuto"
},
"preferencesDescription": "Regola le impostazioni di aggiornamento senza lasciare questa pagina.",
"preferencesTitle": "Toggle Rapidi",
@@ -58,6 +51,12 @@
"documentationDescription": "Guide, FAQ e flussi di lavoro comuni.",
"feedback": "Feedback e problemi",
"feedbackDescription": "Condividi idee o segnala problemi su GitHub.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Segnala bug o richiedi funzionalità su GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Condividi feedback o suggerimenti su X menzionando @nexmoex.",
"discord": "Discord",
"discordDescription": "Unisciti alla nostra community Discord per discussioni e supporto.",
"license": "Licenza",
"licenseDescription": "Rivedi i termini della licenza open-source.",
"website": "Sito web ufficiale",
@@ -76,13 +75,21 @@
"sourceCode": "Il codice sorgente è disponibile",
"title": "Informazioni",
"version": "Versione",
"versionLabel": "v{{version}}"
"versionLabel": "v{{version}}",
"latestVersionBadge": "Ultima: v{{version}}",
"latestVersionStatus": {
"available": "Nuova versione disponibile",
"uptodate": "Sei aggiornato",
"error": "Impossibile recuperare l'ultima versione"
},
"downloadingUpdate": "Download dell'aggiornamento"
},
"advancedOptions": {
"closeWhenDone": "Chiudi app quando il download finisce",
"currentLocation": "Posizione download attuale - ",
"downloadLocation": "Posizione download",
"downloadSubs": "Scarica sottotitoli se disponibili",
"downloadSubsHint": "Salva i sottotitoli come file separati quando disponibili",
"end": "Fine",
"endHint": "Se lasciato vuoto, verrà scaricato fino alla fine",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "Scarica video e audio da centinaia di siti",
"title": "VidBee"
},
"audioExtract": {
"bad": "Cattivo",
"best": "Migliore",
"extract": "Estrai",
"good": "Buono",
"normal": "Normale",
"selectFormat": "Seleziona Formato",
"selectQuality": "Seleziona Qualità",
"title": "Estrai Audio",
"worst": "Peggiore"
},
"download": {
"active": "Attivo",
"all": "Tutto",
@@ -123,6 +119,10 @@
"downloadBtn": "Scarica",
"downloadPending": "In attesa",
"downloadQueue": "Coda Download",
"customDownloadFolder": "Cartella di download personalizzata",
"autoFolderPlaceholder": "Cartella automatica (in base ai metadati)",
"autoFolderHint": "Le cartelle automatiche vengono create dai metadati.",
"useAutoFolder": "Usa cartella automatica",
"downloadVideo": "Scarica Video",
"downloading": "Scaricando...",
"enterUrl": "Inserisci URL Video",
@@ -130,44 +130,17 @@
"error": "Errore",
"fetch": "Recupera",
"fetchingVideoInfo": "Recupero informazioni video...",
"goToSettings": "Vai su Impostazioni",
"hideDetails": "Nascondi dettagli",
"history": "Cronologia",
"imageLoadError": "Errore nel caricamento dell'immagine",
"imagePlaceholder": "Nessuna immagine disponibile",
"infoUnavailable": "Download con Un Clic (Info non disponibile)",
"loading": "Caricamento",
"metadata": {
"audioCodec": "Codec audio",
"codec": "Codec",
"completedAt": "Completato a",
"createdAt": "Creato a",
"description": "Descrizione",
"downloadPath": "Scarica il percorso",
"fileSize": "Dimensioni del file",
"format": "Formato",
"formatNote": "Nota sul formato",
"fps": "FPS",
"height": "Altezza",
"playlist": "Playlist",
"protocol": "Protocollo",
"quality": "Qualità",
"savedFile": "File salvato",
"source": "Fonte",
"speed": "Velocità",
"startedAt": "Iniziato alle",
"subscription": "Sottoscrizione",
"tags": "Tag",
"url": "URL di origine",
"videoCodec": "Codec video",
"views": "Viste",
"width": "Larghezza"
},
"moreOptions": "Più opzioni",
"noActiveDownloads": "Nessun download attivo",
"noAudio": "Nessun Audio",
"noHistory": "Nessuna cronologia download",
"noItems": "Nessun elemento trovato",
"goToSettings": "Vai su Impostazioni",
"oneClickDownload": "Download con Un Clic",
"oneClickDownloadDescription": "Scarica direttamente con impostazioni predefinite senza conferma",
"oneClickDownloadEnabled": "Il download con un clic è abilitato. \nI download verranno avviati direttamente con le impostazioni predefinite.",
@@ -176,14 +149,17 @@
"paste": "Incolla",
"pastePlaylistUrl": "Clicca per incollare link playlist dagli appunti [Ctrl + V]",
"pasteUrl": "Clicca per incollare URL video o ID [Ctrl + V]",
"pasteUrlButton": "Incolla URL",
"preparing": "Preparazione...",
"processing": "Elaborazione",
"progress": "Progresso",
"selectAudioFormat": "Seleziona Formato Audio",
"selectFormat": "Seleziona Formato",
"selectVideoFormat": "Seleziona Formato Video",
"startDownload": "Avvia download",
"showDetails": "Mostra dettagli",
"hideDetails": "Nascondi dettagli",
"selectAudioFormat": "Seleziona Formato Audio",
"selectDownloadType": "Seleziona tipo di download",
"selectFormat": "Seleziona Formato",
"startDownload": "Avvia download",
"selectVideoFormat": "Seleziona Formato Video",
"singleVideo": "Video Singolo",
"speed": "Velocità",
"title": "Titolo",
@@ -193,7 +169,56 @@
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Informazioni Video",
"videoInfoUpdated": "Informazioni video aggiornate"
"videoInfoUpdated": "Informazioni video aggiornate",
"metadata": {
"source": "Fonte",
"playlist": "Playlist",
"format": "Formato",
"quality": "Qualità",
"codec": "Codec",
"savedFile": "File salvato",
"url": "URL di origine",
"description": "Descrizione",
"views": "Viste",
"tags": "Tag",
"downloadPath": "Scarica il percorso",
"createdAt": "Creato a",
"startedAt": "Iniziato alle",
"completedAt": "Completato a",
"speed": "Velocità",
"fileSize": "Dimensioni del file",
"width": "Larghezza",
"height": "Altezza",
"fps": "FPS",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"formatNote": "Nota sul formato",
"protocol": "Protocollo",
"subscription": "Sottoscrizione"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "Questo link GitHub è molto lungo. Se non si apre, apri la pagina dell'issue e incolla i log manualmente."
}
},
"error": {
"title": "Qualcosa è andato storto",
"description": "Si è verificato un errore imprevisto. Ricarica l'app o segnala il problema se persiste.",
"message": "Messaggio di errore",
"unknownError": "Si è verificato un errore sconosciuto",
"goHome": "Vai alla home",
"reload": "Ricarica app",
"copyReport": "Copia rapporto errore",
"copied": "Copiato!",
"copySuccess": "Rapporto di errore copiato negli appunti",
"copyFailed": "Impossibile copiare il rapporto di errore",
"showDetails": "Mostra dettagli",
"hideDetails": "Nascondi dettagli",
"stackTrace": "Traccia dello stack",
"componentStack": "Stack dei componenti",
"noStackTrace": "Nessuna traccia dello stack disponibile",
"fullReport": "Rapporto di errore completo",
"helpText": "Se l'errore persiste, copia il rapporto di errore sopra e condividilo con il team di supporto. Le informazioni di contatto sono nella pagina Informazioni."
},
"errors": {
"clickToCopy": "Clicca per copiare i dettagli",
@@ -203,6 +228,7 @@
"emptyUrl": "Inserisci un URL",
"errorDetails": "Dettagli Errore",
"fetchInfoFailed": "Errore nel recupero delle informazioni video",
"invalidUrl": "Il contenuto degli appunti non è un URL valido",
"networkError": "Si è verificato un errore. Controlla la tua rete e usa un URL corretto",
"pasteFromClipboard": "Errore nell'incollare dagli appunti"
},
@@ -210,10 +236,23 @@
"clearCancelled": "Cancella Annullati",
"clearCompleted": "Cancella Completati",
"clearErrors": "Cancella Errori",
"clearAll": "Cancella tutta la cronologia",
"clearAllAction": "Cancella cronologia",
"clearSelection": "Cancella selezione",
"confirmClearAllTitle": "Cancellare tutta la cronologia?",
"confirmClearAllDescription": "Rimuovi {{count}} elementi dalla cronologia. I file rimangono sul disco.",
"confirmDeleteSelectedTitle": "Rimuovere gli elementi selezionati?",
"confirmDeleteSelectedDescription": "Rimuovi {{count}} elementi dalla cronologia. I file rimangono sul disco.",
"alsoDeleteFiles": "Elimina anche i file",
"confirmDeletePlaylistTitle": "Rimuovere la cronologia della playlist?",
"confirmDeletePlaylistDescription": "Rimuovi {{count}} elementi da {{title}} ed elimina i loro file.",
"copyToClipboard": "Copia negli appunti",
"copyUrl": "Copia URL",
"date": "Data",
"deletePlaylist": "Rimuovi playlist",
"deleteSelected": "Rimuovi selezionati",
"description": "Visualizza e gestisci la tua cronologia download",
"doneSelecting": "Fatto",
"duration": "Durata",
"fileSize": "Dimensione File",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "Apri Posizione File",
"openFolder": "Apri Cartella",
"openInBrowser": "Clicca per aprire nel browser",
"removeAction": "Rimuovi",
"removeItem": "Rimuovi Elemento",
"select": "Seleziona",
"selectAll": "Seleziona tutto",
"selectVisible": "Seleziona visibili",
"selectItem": "Seleziona elemento",
"selectedCount": "{{count}} selezionati",
"selectionSummary": "{{selected}} di {{total}} visibili selezionati",
"stats": {
"cancelled": "Annullato",
"completed": "Completato",
@@ -247,9 +293,9 @@
"about": "Informazioni",
"download": "Scarica",
"playlist": "Scarica Playlist",
"preferences": "Preferenze",
"rss": "RSS",
"subscriptions": "Abbonamenti",
"preferences": "Preferenze",
"supportedSites": "Siti Supportati",
"theme": "Tema:"
},
@@ -258,9 +304,15 @@
"downloadCompleted": "Download completato",
"downloadFailed": "Download fallito",
"downloadStarted": "Download iniziato",
"historyCleared": "Cronologia cancellata",
"historyClearFailed": "Impossibile cancellare la cronologia",
"itemRemoved": "Elemento rimosso",
"itemsRemoved": "{{count}} elementi rimossi",
"itemsRemoveFailed": "Impossibile rimuovere gli elementi selezionati",
"openFileFailed": "Errore nell'apertura del file",
"openFolderFailed": "Errore nell'apertura della cartella",
"playlistHistoryRemoved": "Playlist rimossa e file eliminati",
"playlistHistoryRemoveFailed": "Impossibile rimuovere la cronologia della playlist",
"removeFailed": "Errore nella rimozione dell'elemento",
"settingsSaved": "Impostazioni salvate",
"urlCopied": "URL copiato negli appunti",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Anteprima chiara",
"collapsedProgress": "Download playlist: {{completed}} / {{total}} completati",
"comingSoon": "La funzionalità di download playlist arriverà presto!",
"completed": "Playlist scaricata",
"description": "Scarica tutti i video da una playlist o canale YouTube",
@@ -284,8 +337,10 @@
"folderFormat": "Formato nome cartella per playlist",
"foundVideos": "Trovati {{count}} video nella playlist",
"groupActive": "{{count}} attivi",
"groupCollapse": "Comprimi",
"groupErrors": "{{count}} non riuscito",
"groupSummary": "{{completato}} / {{totale}} completati",
"groupExpand": "Espandi",
"groupSummary": "{{completed}} / {{total}} completati",
"linkLabel": "URL Playlist",
"noEntries": "Nessun video trovato in questa playlist",
"noEntriesInRange": "Nessun video nell'intervallo selezionato",
@@ -294,12 +349,16 @@
"positionLabel": "Articolo {{index}} di {{total}}",
"previewButton": "Anteprima della playlist",
"previewFailed": "Impossibile visualizzare l'anteprima della playlist",
"previewRequired": "Anteprima della playlist prima del download.",
"previewSummary": "Anteprima degli elementi della playlist prima del download.",
"previewRequired": "Anteprima della playlist prima del download.",
"range": "Intervallo (Opzionale)",
"resetToDefault": "Ripristina predefinito",
"selectedRange": "Intervallo: {{inizio}}-{{fine}}",
"selectedRange": "Intervallo: {{start}}-{{end}}",
"selectedVideos": "{{count}} selezionati",
"downloadCurrentRange": "Scarica selezionati",
"showingCount": "Visualizzazione di {{count}} video",
"selectEntry": "Seleziona voce {{index}}",
"noEntriesSelected": "Nessuna voce selezionata",
"startIndex": "Inizio (1)",
"title": "Scarica Playlist",
"totalVideos": "Video totali: {{count}}",
@@ -312,39 +371,62 @@
"audio": "Preferenze Audio",
"browserForCookies": "Seleziona browser per usare i cookie",
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
"browserForCookiesWindowsNote": "Su Windows sono supportati solo i cookie di Firefox. Per altri browser, configura manualmente un file di cookie.",
"browserForCookiesProfile": "Nome profilo o percorso",
"browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.",
"browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)",
"browserForCookiesProfileInvalid": "Il percorso del profilo non è valido. Scegli la cartella del profilo del browser selezionato.",
"browserForCookiesProfileInvalidPath": "Quella cartella non esiste. Scegli una cartella del profilo esistente.",
"browserForCookiesProfileInvalidProfile": "Nome profilo non trovato nella posizione predefinita del browser.",
"browserForCookiesProfileInvalidUnsupported": "Nessuna posizione di profilo predefinita nota per questo browser su questa piattaforma.",
"browserForCookiesProfileInvalidEmpty": "Inserisci un percorso del profilo per il browser selezionato.",
"cookiesFile": "Archivio dei cookie",
"cookiesFileDescription": "File cookie formattato per Netscape da caricare per l'autenticazione",
"clearCookiesFile": "Chiaro",
"cookiesHelpTitle": "Utilizzo dei cookie",
"cookiesHelpBrowser": "Scegli il tuo browser qui sopra per riutilizzare automaticamente la sessione a cui hai effettuato l'accesso.",
"cookiesHelpFile": "Esporta un file cookie di Netscape (vedi le FAQ yt-dlp) e selezionalo qui quando necessario.",
"cookiesHelpFaq": "Apri le domande frequenti sui cookie yt-dlp",
"openLinkError": "Impossibile aprire il collegamento",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"clearConfigFile": "Chiaro",
"clearCookiesFile": "Chiaro",
"configFile": "Usa file di configurazione",
"configFileDescription": "File di configurazione personalizzato per yt-dlp",
"cookiesFile": "Archivio dei cookie",
"cookiesFileDescription": "File cookie formattato per Netscape da caricare per l'autenticazione",
"cookiesHelpBrowser": "Scegli il tuo browser qui sopra per riutilizzare automaticamente la sessione a cui hai effettuato l'accesso.",
"cookiesHelpFaq": "Apri le domande frequenti sui cookie yt-dlp",
"cookiesHelpFile": "Esporta un file cookie di Netscape (vedi le FAQ yt-dlp) e selezionalo qui quando necessario.",
"cookiesHelpTitle": "Utilizzo dei cookie",
"clearConfigFile": "Chiaro",
"dark": "Scuro",
"description": "Configura le tue preferenze di download e impostazioni dell'app",
"directorySelectError": "Errore nella selezione della directory",
"downloadPath": "Posizione download",
"downloadPathDescription": "Scegli dove salvare i file scaricati",
"enableAnalytics": "Aiutaci a migliorare VidBee",
"enableAnalyticsDescription": "Condividi dati di utilizzo anonimi per aiutarci a capire come viene utilizzata l'app e dare priorità ai miglioramenti.",
"fileSelectError": "Errore nella selezione del file",
"general": "Generale",
"language": "Lingua",
"languageDescription": "Scegli la lingua preferita per l'interfaccia dell'applicazione",
"light": "Chiaro",
"hideDockIcon": "Nascondi l'icona del Dock",
"hideDockIconDescription": "Rimuovi VidBee dal Dock di macOS. \nUtilizza la barra dei menu o l'icona nella barra delle applicazioni per riaprire l'app.",
"language": "Lingua",
"launchAtLogin": "Avvia all'avvio",
"launchAtLoginDescription": "Apri VidBee automaticamente dopo aver effettuato l'accesso al tuo computer.",
"launchAtLoginUnsupported": "L'avvio automatico è disponibile solo su macOS e Windows.",
"light": "Chiaro",
"enableAnalytics": "Aiutaci a migliorare VidBee",
"enableAnalyticsDescription": "Condividi dati di utilizzo anonimi per aiutarci a capire come viene utilizzata l'app e dare priorità ai miglioramenti.",
"embedChapters": "Incorpora capitoli",
"embedChaptersDescription": "Aggiungi marcatori di capitolo al file quando disponibili",
"embedMetadata": "Incorpora metadati",
"embedMetadataDescription": "Scrivi titolo, artista e altri metadati quando disponibili",
"embedSubs": "Incorpora sottotitoli",
"embedSubsDescription": "Incorpora i sottotitoli nel file video (mp4, webm, mkv)",
"embedThumbnail": "Incorpora miniatura",
"embedThumbnailDescription": "Aggiungi la miniatura come copertina",
"maxConcurrentDownloads": "Numero massimo di download attivi",
"maxConcurrentDownloadsDescription": "Numero massimo di download simultanei",
"none": "Nessuno",
@@ -362,7 +444,6 @@
"normal": "Normale",
"worst": "Peggiore"
},
"openLinkError": "Impossibile aprire il collegamento",
"proxy": "Proxy",
"proxyDescription": "Server proxy per le richieste di rete",
"proxyPlaceholder": "http://proxy:port",
@@ -371,8 +452,7 @@
"showMoreFormats": "Mostra più opzioni formato",
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
"subscriptionDefaults": {
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.",
"intervalDescription": "La frequenza con cui VidBee controlla ciascun feed di abbonamento (1-24 ore)."
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file."
},
"system": "Sistema",
"theme": "Tema",
@@ -384,6 +464,120 @@
},
"video": "Preferenze Video"
},
"subscriptions": {
"title": "Abbonamenti",
"subtitle": "{{count}} abbonamento{{count, plural, one {} other {s}}}",
"description": "Monitora automaticamente i feed RSS e accoda i nuovi download senza lavoro manuale.",
"defaults": {
"title": "Impostazioni predefinite dell'automazione",
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
"downloadDirectory": "Scarica la directory",
"filenameTemplate": "Modello nome file (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Se abilitato, VidBee salta gli elementi del backlog più vecchi e acquisisce solo il caricamento più recente."
},
"add": {
"title": "Aggiungi RSS",
"description": "Incolla un collegamento al feed RSS. \nVidBee rileverà automaticamente il feed."
},
"fields": {
"url": "URL del feed",
"keywords": "Filtro parole chiave (separati da virgole)",
"tags": "Tag automatici",
"customDirectory": "Directory personalizzata",
"namingTemplate": "Modello nome file personalizzato (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Ignora gli elementi del backlog e recupera solo il caricamento più recente da questo feed.",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"onlyLatestShort": "Solo più recente"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Aggiungere",
"refresh": "Aggiorna",
"edit": "Modificare",
"remove": "Rimuovere",
"save": "Salva modifiche",
"selectDirectory": "Sfoglia",
"enable": "Abilitare",
"disable": "Disabilita"
},
"items": {
"title": "Ultimi caricamenti ({{count}})",
"count": "{{count}} articoli",
"empty": "Nessun elemento del feed recente trovato.",
"status": {
"queued": "In coda",
"notQueued": "Non in coda",
"pending": "In attesa di",
"downloading": "Download in corso",
"processing": "Elaborazione",
"completed": "Completato",
"error": "Fallito",
"cancelled": "Annullato"
},
"fromChannel": "Da {{channel}}",
"tooltip": {
"downloadStatus": "Stato del download: {{status}}",
"downloadPending": "In attesa dei dettagli per il download...",
"notQueued": "Non ancora nella coda di download"
},
"actions": {
"open": "Apri nel browser",
"queue": "Aggiungi alla coda di download"
}
},
"labels": {
"subscription": "Sottoscrizione",
"unknown": "Abbonamento sconosciuto",
"noThumbnail": "Nessuna miniatura"
},
"notifications": {
"directoryError": "Impossibile aprire il selettore di directory.",
"missingUrl": "Incolla prima il collegamento al canale.",
"created": "Abbonamento aggiunto",
"createError": "Impossibile aggiungere l'abbonamento.",
"refreshStarted": "Aggiornamento avviato",
"removed": "Abbonamento rimosso",
"updated": "Abbonamento aggiornato",
"itemQueued": "Aggiunto alla coda di download",
"itemAlreadyQueued": "Questo video è già in coda",
"queueError": "Impossibile aggiungere alla coda di download.",
"openLinkError": "Impossibile aprire il collegamento video.",
"resolveError": "Impossibile risolvere l'URL del feed RSS.",
"duplicateUrl": "Questo feed RSS è già sottoscritto."
},
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
"detecting": "Rilevamento alimentazione...",
"latestVideo": "Ultimo video: {{title}}",
"lastChecked": "Ultimo controllo: {{time}}",
"never": "Mai",
"empty": "Nessun abbonamento ancora. \nAggiungi i tuoi canali preferiti per avviare il download automatico.",
"edit": {
"title": "Modifica {{name}}",
"description": "Modifica filtri, tag e sostituzioni per questo feed."
},
"status": {
"title": "Stato",
"up-to-date": "Aggiornato",
"checking": "Controllo",
"failed": "Fallito",
"idle": "Oziare",
"tooltip": {
"updatedAt": "Aggiornato: {{time}}"
}
},
"rssHub": {
"title": "Abbonamenti automatizzati con RSSHub",
"description": "Combina VidBee con RSSHub per abilitare abbonamenti e download automatizzati da varie piattaforme. \nUna volta configurato, VidBee viene eseguito in background e scarica automaticamente i video e i contenuti più recenti.",
"learnMore": "Ulteriori informazioni su RSSHub",
"openDocs": "Apri la documentazione RSSHub",
"hint": "Non hai l'URL del feed RSS? \nUtilizza RSSHub per generare feed RSS per YouTube, Twitter e migliaia di altre piattaforme."
}
},
"sites": {
"homeInlineDescription": "Supporta {{sites}} e altro.",
"moreDescription": "La lista completa yt-dlp viene aggiornata costantemente dalla comunità.",
@@ -468,119 +662,5 @@
},
"popularSection": "Piattaforme principali",
"viewAll": "Visualizza tutti i siti supportati"
},
"subscriptions": {
"actions": {
"add": "Aggiungere",
"disable": "Disabilita",
"edit": "Modificare",
"enable": "Abilitare",
"refresh": "Aggiorna",
"remove": "Rimuovere",
"save": "Salva modifiche",
"selectDirectory": "Sfoglia"
},
"add": {
"description": "Incolla un collegamento al feed RSS. \nVidBee rileverà automaticamente il feed.",
"title": "Aggiungi RSS"
},
"defaults": {
"checkInterval": "Intervallo di controllo (ore)",
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
"downloadDirectory": "Scarica la directory",
"filenameTemplate": "Modello nome file (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Se abilitato, VidBee salta gli elementi del backlog più vecchi e acquisisce solo il caricamento più recente.",
"title": "Impostazioni predefinite dell'automazione"
},
"description": "Monitora automaticamente i feed RSS e accoda i nuovi download senza lavoro manuale.",
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
"detecting": "Rilevamento alimentazione...",
"edit": {
"description": "Modifica filtri, tag e sostituzioni per questo feed.",
"title": "Modifica {{nome}}"
},
"empty": "Nessun abbonamento ancora. \nAggiungi i tuoi canali preferiti per avviare il download automatico.",
"fields": {
"customDirectory": "Directory personalizzata",
"disabled": "Disabilitato",
"enabled": "Abilitato",
"keywords": "Filtro parole chiave (separati da virgole)",
"namingTemplate": "Modello nome file personalizzato (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Ignora gli elementi del backlog e recupera solo il caricamento più recente da questo feed.",
"onlyLatestShort": "Solo più recente",
"tags": "Tag automatici",
"url": "URL del feed"
},
"items": {
"actions": {
"open": "Apri nel browser",
"queue": "Aggiungi alla coda di download"
},
"count": "{{count}} articoli",
"empty": "Nessun elemento del feed recente trovato.",
"fromChannel": "Da {{canale}}",
"status": {
"cancelled": "Annullato",
"completed": "Completato",
"downloading": "Download in corso",
"error": "Fallito",
"notQueued": "Non in coda",
"pending": "In attesa di",
"processing": "Elaborazione",
"queued": "In coda"
},
"title": "Ultimi caricamenti ({{count}})",
"tooltip": {
"downloadPending": "In attesa dei dettagli per il download...",
"downloadStatus": "Stato del download: {{status}}",
"notQueued": "Non ancora nella coda di download"
}
},
"labels": {
"noThumbnail": "Nessuna miniatura",
"subscription": "Sottoscrizione",
"unknown": "Abbonamento sconosciuto"
},
"lastChecked": "Ultimo controllo: {{time}}",
"latestVideo": "Ultimo video: {{title}}",
"never": "Mai",
"notifications": {
"createError": "Impossibile aggiungere l'abbonamento.",
"created": "Abbonamento aggiunto",
"directoryError": "Impossibile aprire il selettore di directory.",
"itemAlreadyQueued": "Questo video è già in coda",
"itemQueued": "Aggiunto alla coda di download",
"missingUrl": "Incolla prima il collegamento al canale.",
"openLinkError": "Impossibile aprire il collegamento video.",
"queueError": "Impossibile aggiungere alla coda di download.",
"refreshStarted": "Aggiornamento avviato",
"removed": "Abbonamento rimosso",
"resolveError": "Impossibile risolvere l'URL del feed RSS.",
"updated": "Abbonamento aggiornato"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"rssHub": {
"description": "Combina VidBee con RSSHub per abilitare abbonamenti e download automatizzati da varie piattaforme. \nUna volta configurato, VidBee viene eseguito in background e scarica automaticamente i video e i contenuti più recenti.",
"hint": "Non hai l'URL del feed RSS? \nUtilizza RSSHub per generare feed RSS per YouTube, Twitter e migliaia di altre piattaforme.",
"learnMore": "Ulteriori informazioni su RSSHub",
"openDocs": "Apri la documentazione RSSHub",
"title": "Abbonamenti automatizzati con RSSHub"
},
"status": {
"checking": "Controllo",
"failed": "Fallito",
"idle": "Oziare",
"title": "Stato",
"tooltip": {
"updatedAt": "Aggiornato: {{time}}"
},
"up-to-date": "Aggiornato"
},
"subtitle": "{{count}} abbonamento{{count, plural, one {} other {s}}}",
"title": "Abbonamenti"
}
}

View File

@@ -16,7 +16,6 @@
"betaProgramDescription": "他の人より先に早期ビルドと今後の機能を受け取ります。",
"betaProgramTitle": "プレビューチャンネル",
"description": "VidBeeはElectronで構築され、yt-dlpによって動力を得る無料のオープンソースダウンローダーです。",
"downloadingUpdate": "アップデートをダウンロードしています",
"followAuthorActions": {
"follow": "@nexmoexをフォロー"
},
@@ -25,12 +24,6 @@
"followAuthorTitle": "開発者をフォロー",
"here": "ここ",
"homepage": "ホームページ",
"latestVersionBadge": "最新: v{{version}}",
"latestVersionStatus": {
"available": "新しいバージョンが利用可能",
"error": "最新バージョンを取得できません",
"uptodate": "最新バージョンを使用中"
},
"notifications": {
"checkingUpdates": "アップデートを検索中...",
"downloadError": "アップデートのダウンロードに失敗",
@@ -38,14 +31,14 @@
"downloadUpdate": "アップデート{{version}}をダウンロードしてインストールしますか?",
"manualDownloadAction": "今すぐダウンロード",
"noUpdatesAvailable": "最新バージョンを使用しています",
"restartNowAction": "今すぐ再起動してください",
"restartToUpdate": "今すぐ再起動してアップデートをインストールしますか?",
"unknownErrorFallback": "不明なエラー",
"restartNowAction": "今すぐ再起動してください",
"updateAvailable": "利用可能なアップデート:{{version}}",
"updateAvailableMessage": "新しいバージョン {{version}} が利用可能です。\n公式サイトからダウンロードしてください。",
"updateDownloaded": "アップデートがダウンロードされました。再起動してインストールしてください",
"updateDownloadedVersion": "アップデート {{version}} をダウンロードしました。再起動してインストールしてください",
"updateError": "アップデートの確認に失敗:{{error}}"
"updateError": "アップデートの確認に失敗:{{error}}",
"unknownErrorFallback": "不明なエラー"
},
"preferencesDescription": "このページを離れることなくアップデート設定を調整します。",
"preferencesTitle": "クイックトグル",
@@ -58,6 +51,12 @@
"documentationDescription": "ガイド、FAQ、一般的なワークフロー。",
"feedback": "フィードバックと問題",
"feedbackDescription": "GitHubでアイデアを共有したり問題を報告したりしてください。",
"githubIssues": "GitHub",
"githubIssuesDescription": "GitHub でバグ報告や機能要望を送ってください。",
"xFeedback": "Twitter",
"xFeedbackDescription": "X で @nexmoex に言及してフィードバックや提案を共有してください。",
"discord": "Discord",
"discordDescription": "Discord コミュニティに参加して、議論やサポートを受けてください。",
"license": "ライセンス",
"licenseDescription": "オープンソースライセンス条項を確認してください。",
"website": "公式ウェブサイト",
@@ -76,13 +75,21 @@
"sourceCode": "ソースコードが利用可能",
"title": "について",
"version": "バージョン",
"versionLabel": "v{{version}}"
"versionLabel": "v{{version}}",
"latestVersionBadge": "最新: v{{version}}",
"latestVersionStatus": {
"available": "新しいバージョンが利用可能",
"uptodate": "最新バージョンを使用中",
"error": "最新バージョンを取得できません"
},
"downloadingUpdate": "アップデートをダウンロードしています"
},
"advancedOptions": {
"closeWhenDone": "ダウンロード完了時にアプリを閉じる",
"currentLocation": "現在のダウンロード場所 - ",
"downloadLocation": "ダウンロード場所",
"downloadSubs": "利用可能な場合は字幕をダウンロード",
"downloadSubsHint": "利用可能な場合は字幕を別ファイルとして保存",
"end": "終了",
"endHint": "空のままにすると最後までダウンロードされます",
"endPlaceholder": "10:00",
@@ -98,17 +105,6 @@
"description": "数百のサイトからビデオとオーディオをダウンロード",
"title": "VidBee"
},
"audioExtract": {
"bad": "悪い",
"best": "最高",
"extract": "抽出",
"good": "良い",
"normal": "通常",
"selectFormat": "フォーマットを選択",
"selectQuality": "品質を選択",
"title": "オーディオを抽出",
"worst": "最悪"
},
"download": {
"active": "アクティブ",
"all": "すべて",
@@ -123,6 +119,10 @@
"downloadBtn": "ダウンロード",
"downloadPending": "保留中",
"downloadQueue": "ダウンロードキュー",
"customDownloadFolder": "カスタムダウンロードフォルダー",
"autoFolderPlaceholder": "自動フォルダー(メタデータに基づく)",
"autoFolderHint": "自動フォルダーはメタデータから作成されます。",
"useAutoFolder": "自動フォルダーを使用",
"downloadVideo": "ビデオをダウンロード",
"downloading": "ダウンロード中...",
"enterUrl": "ビデオURLを入力",
@@ -130,44 +130,17 @@
"error": "エラー",
"fetch": "取得",
"fetchingVideoInfo": "ビデオ情報を取得中...",
"goToSettings": "設定に移動",
"hideDetails": "詳細を隠す",
"history": "履歴",
"imageLoadError": "画像の読み込みに失敗",
"imagePlaceholder": "利用可能な画像なし",
"infoUnavailable": "ワンクリックダウンロード(情報利用不可)",
"loading": "読み込み中",
"metadata": {
"audioCodec": "オーディオコーデック",
"codec": "コーデック",
"completedAt": "完了時刻",
"createdAt": "で作成されました",
"description": "説明",
"downloadPath": "ダウンロードパス",
"fileSize": "ファイルサイズ",
"format": "形式",
"formatNote": "メモのフォーマット",
"fps": "FPS",
"height": "身長",
"playlist": "プレイリスト",
"protocol": "プロトコル",
"quality": "品質",
"savedFile": "保存されたファイル",
"source": "ソース",
"speed": "スピード",
"startedAt": "に開始",
"subscription": "サブスクリプション",
"tags": "タグ",
"url": "ソースURL",
"videoCodec": "ビデオコーデック",
"views": "ビュー",
"width": "幅"
},
"moreOptions": "その他のオプション",
"noActiveDownloads": "アクティブなダウンロードなし",
"noAudio": "オーディオなし",
"noHistory": "ダウンロード履歴なし",
"noItems": "アイテムが見つかりません",
"goToSettings": "設定に移動",
"oneClickDownload": "ワンクリックダウンロード",
"oneClickDownloadDescription": "確認なしでデフォルト設定で直接ダウンロード",
"oneClickDownloadEnabled": "ワンクリックダウンロードが有効になります。\nダウンロードはデフォルト設定で直接開始されます。",
@@ -176,14 +149,17 @@
"paste": "貼り付け",
"pastePlaylistUrl": "クリップボードからプレイリストリンクを貼り付け [Ctrl + V]",
"pasteUrl": "ビデオURLまたはIDを貼り付け [Ctrl + V]",
"pasteUrlButton": "URL を貼り付け",
"preparing": "準備中...",
"processing": "処理中",
"progress": "進行状況",
"selectAudioFormat": "オーディオフォーマットを選択",
"selectFormat": "フォーマットを選択",
"selectVideoFormat": "ビデオフォーマットを選択",
"startDownload": "ダウンロードを開始",
"showDetails": "詳細を表示",
"hideDetails": "詳細を隠す",
"selectAudioFormat": "オーディオフォーマットを選択",
"selectDownloadType": "ダウンロードの種類を選択",
"selectFormat": "フォーマットを選択",
"startDownload": "ダウンロードを開始",
"selectVideoFormat": "ビデオフォーマットを選択",
"singleVideo": "単一ビデオ",
"speed": "速度",
"title": "タイトル",
@@ -193,7 +169,56 @@
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "ビデオ",
"videoInfo": "ビデオ情報",
"videoInfoUpdated": "ビデオ情報が更新されました"
"videoInfoUpdated": "ビデオ情報が更新されました",
"metadata": {
"source": "ソース",
"playlist": "プレイリスト",
"format": "形式",
"quality": "品質",
"codec": "コーデック",
"savedFile": "保存されたファイル",
"url": "ソースURL",
"description": "説明",
"views": "ビュー",
"tags": "タグ",
"downloadPath": "ダウンロードパス",
"createdAt": "で作成されました",
"startedAt": "に開始",
"completedAt": "完了時刻",
"speed": "スピード",
"fileSize": "ファイルサイズ",
"width": "幅",
"height": "身長",
"fps": "FPS",
"videoCodec": "ビデオコーデック",
"audioCodec": "オーディオコーデック",
"formatNote": "メモのフォーマット",
"protocol": "プロトコル",
"subscription": "サブスクリプション"
},
"feedback": {
"title": "Report this error:",
"githubUrlTooLong": "このGitHubリンクは非常に長いです。開けない場合は、issueページを開いてログを手動で貼り付けてください。"
}
},
"error": {
"title": "問題が発生しました",
"description": "予期しないエラーが発生しました。アプリを再読み込みするか、問題が続く場合は報告してください。",
"message": "エラーメッセージ",
"unknownError": "不明なエラーが発生しました",
"goHome": "ホームへ",
"reload": "アプリを再読み込み",
"copyReport": "エラーレポートをコピー",
"copied": "コピーしました!",
"copySuccess": "エラーレポートをクリップボードにコピーしました",
"copyFailed": "エラーレポートのコピーに失敗しました",
"showDetails": "詳細を表示",
"hideDetails": "詳細を非表示",
"stackTrace": "スタックトレース",
"componentStack": "コンポーネントスタック",
"noStackTrace": "利用可能なスタックトレースはありません",
"fullReport": "完全なエラーレポート",
"helpText": "このエラーが続く場合は、上記のエラーレポートをコピーしてサポートチームに共有してください。連絡先は「概要」ページにあります。"
},
"errors": {
"clickToCopy": "詳細をコピーするにはクリック",
@@ -203,6 +228,7 @@
"emptyUrl": "URLを入力してください",
"errorDetails": "エラーの詳細",
"fetchInfoFailed": "ビデオ情報の取得に失敗",
"invalidUrl": "クリップボードの内容が有効なURLではありません",
"networkError": "エラーが発生しました。ネットワークを確認し、正しいURLを使用してください",
"pasteFromClipboard": "クリップボードからの貼り付けに失敗"
},
@@ -210,10 +236,23 @@
"clearCancelled": "キャンセル済みをクリア",
"clearCompleted": "完了をクリア",
"clearErrors": "エラーをクリア",
"clearAll": "履歴をすべてクリア",
"clearAllAction": "履歴をクリア",
"clearSelection": "選択をクリア",
"confirmClearAllTitle": "履歴をすべてクリアしますか?",
"confirmClearAllDescription": "履歴から {{count}} 件を削除します。ファイルはディスクに残ります。",
"confirmDeleteSelectedTitle": "選択した項目を削除しますか?",
"confirmDeleteSelectedDescription": "履歴から {{count}} 件を削除します。ファイルはディスクに残ります。",
"alsoDeleteFiles": "ファイルも削除",
"confirmDeletePlaylistTitle": "プレイリスト履歴を削除しますか?",
"confirmDeletePlaylistDescription": "{{title}} から {{count}} 件を削除し、ファイルを削除します。",
"copyToClipboard": "クリップボードにコピー",
"copyUrl": "URLをコピー",
"date": "日付",
"deletePlaylist": "プレイリストを削除",
"deleteSelected": "選択した項目を削除",
"description": "ダウンロード履歴を表示および管理",
"doneSelecting": "完了",
"duration": "期間",
"fileSize": "ファイルサイズ",
"filters": {
@@ -229,7 +268,14 @@
"openFileLocation": "ファイルの場所を開く",
"openFolder": "フォルダを開く",
"openInBrowser": "ブラウザで開くにはクリック",
"removeAction": "削除",
"removeItem": "アイテムを削除",
"select": "選択",
"selectAll": "すべて選択",
"selectVisible": "表示中を選択",
"selectItem": "項目を選択",
"selectedCount": "{{count}} 件選択",
"selectionSummary": "{{total}} 件中 {{selected}} 件を選択",
"stats": {
"cancelled": "キャンセル済み",
"completed": "完了",
@@ -247,9 +293,9 @@
"about": "について",
"download": "ダウンロード",
"playlist": "プレイリストをダウンロード",
"preferences": "設定",
"rss": "RSS",
"subscriptions": "定期購入",
"preferences": "設定",
"supportedSites": "サポートされているサイト",
"theme": "テーマ:"
},
@@ -258,9 +304,15 @@
"downloadCompleted": "ダウンロード完了",
"downloadFailed": "ダウンロードに失敗",
"downloadStarted": "ダウンロード開始",
"historyCleared": "履歴をクリアしました",
"historyClearFailed": "履歴のクリアに失敗しました",
"itemRemoved": "アイテムが削除されました",
"itemsRemoved": "{{count}} 件を削除しました",
"itemsRemoveFailed": "選択した項目の削除に失敗しました",
"openFileFailed": "ファイルの開封に失敗",
"openFolderFailed": "フォルダの開封に失敗",
"playlistHistoryRemoved": "プレイリストを削除し、ファイルを削除しました",
"playlistHistoryRemoveFailed": "プレイリスト履歴の削除に失敗しました",
"removeFailed": "アイテムの削除に失敗",
"settingsSaved": "設定が保存されました",
"urlCopied": "URLがクリップボードにコピーされました",
@@ -269,6 +321,7 @@
"playlist": {
"badgeLabel": "プレイリスト",
"clearPreview": "プレビューをクリアする",
"collapsedProgress": "プレイリストをダウンロード中: {{completed}} / {{total}} 完了",
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
"completed": "プレイリストがダウンロードされました",
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
@@ -284,8 +337,10 @@
"folderFormat": "プレイリスト用フォルダ名フォーマット",
"foundVideos": "プレイリストで{{count}}個のビデオを発見",
"groupActive": "{{count}} 個がアクティブです",
"groupCollapse": "折りたたむ",
"groupErrors": "{{count}} 回失敗しました",
"groupSummary": "{{完了}} / {{合計}} 完了",
"groupExpand": "展開",
"groupSummary": "{{completed}} / {{total}} 完了",
"linkLabel": "プレイリストURL",
"noEntries": "このプレイリストにはビデオが見つかりませんでした",
"noEntriesInRange": "選択した範囲にビデオがありません",
@@ -294,12 +349,16 @@
"positionLabel": "{{total}} 中のアイテム {{index}}",
"previewButton": "プレイリストをプレビューする",
"previewFailed": "プレイリストのプレビューに失敗しました",
"previewRequired": "ダウンロードする前にプレイリストをプレビューします。",
"previewSummary": "ダウンロードする前にプレイリスト項目をプレビューします。",
"previewRequired": "ダウンロードする前にプレイリストをプレビューします。",
"range": "範囲(オプション)",
"resetToDefault": "デフォルトにリセット",
"selectedRange": "範囲: {{開始}}-{{終了}}",
"selectedRange": "範囲: {{start}}-{{end}}",
"selectedVideos": "{{count}} 件選択",
"downloadCurrentRange": "選択項目をダウンロード",
"showingCount": "{{count}} 本の動画を表示しています",
"selectEntry": "項目 {{index}} を選択",
"noEntriesSelected": "選択された項目はありません",
"startIndex": "開始1",
"title": "プレイリストをダウンロード",
"totalVideos": "合計動画: {{count}}",
@@ -312,39 +371,62 @@
"audio": "オーディオ設定",
"browserForCookies": "Cookieに使用するブラウザを選択",
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
"browserForCookiesWindowsNote": "Windows では Firefox の Cookie のみ対応しています。他のブラウザは Cookie ファイルを手動で設定してください。",
"browserForCookiesProfile": "プロファイル名またはパス",
"browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。",
"browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)",
"browserForCookiesProfileInvalid": "プロファイルパスが無効です。選択したブラウザのプロファイルフォルダーを選んでください。",
"browserForCookiesProfileInvalidPath": "そのフォルダーは存在しません。既存のプロファイルフォルダーを選んでください。",
"browserForCookiesProfileInvalidProfile": "既定のブラウザ場所にプロファイル名が見つかりません。",
"browserForCookiesProfileInvalidUnsupported": "このプラットフォームではこのブラウザの既定のプロファイル場所が不明です。",
"browserForCookiesProfileInvalidEmpty": "選択したブラウザのプロファイルパスを入力してください。",
"cookiesFile": "クッキーファイル",
"cookiesFileDescription": "認証のためにロードする Netscape 形式の Cookie ファイル",
"clearCookiesFile": "クリア",
"cookiesHelpTitle": "クッキーの使用",
"cookiesHelpBrowser": "サインイン セッションを自動的に再利用するには、上記のブラウザーを選択してください。",
"cookiesHelpFile": "Netscape Cookie ファイルをエクスポートし (yt-dlp FAQ を参照)、必要に応じてここで選択します。",
"cookiesHelpFaq": "yt-dlp Cookie を開くに関するよくある質問",
"openLinkError": "リンクを開けませんでした",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"clearConfigFile": "クリア",
"clearCookiesFile": "クリア",
"configFile": "設定ファイルを使用",
"configFileDescription": "yt-dlp用のカスタム設定ファイル",
"cookiesFile": "クッキーファイル",
"cookiesFileDescription": "認証のためにロードする Netscape 形式の Cookie ファイル",
"cookiesHelpBrowser": "サインイン セッションを自動的に再利用するには、上記のブラウザーを選択してください。",
"cookiesHelpFaq": "yt-dlp Cookie を開くに関するよくある質問",
"cookiesHelpFile": "Netscape Cookie ファイルをエクスポートし (yt-dlp FAQ を参照)、必要に応じてここで選択します。",
"cookiesHelpTitle": "クッキーの使用",
"clearConfigFile": "クリア",
"dark": "ダーク",
"description": "ダウンロード設定とアプリ設定を構成",
"directorySelectError": "ディレクトリの選択に失敗",
"downloadPath": "ダウンロード場所",
"downloadPathDescription": "ダウンロードファイルの保存場所を選択",
"enableAnalytics": "VidBee の改善にご協力ください",
"enableAnalyticsDescription": "匿名の使用状況データを共有することで、アプリの使用状況を把握し、改善の優先順位を付けることができます。",
"fileSelectError": "ファイルの選択に失敗",
"general": "一般",
"language": "言語",
"languageDescription": "アプリのインターフェースに使用する言語を選択してください",
"light": "ライト",
"hideDockIcon": "ドックアイコンを非表示にする",
"hideDockIconDescription": "VidBee を macOS Dock から削除します。\nメニュー バーまたはトレイ アイコンを使用して、アプリを再度開きます。",
"language": "言語",
"launchAtLogin": "起動時に起動する",
"launchAtLoginDescription": "コンピューターにサインインした後、VidBee を自動的に開きます。",
"launchAtLoginUnsupported": "自動起動は macOS と Windows でのみ利用できます。",
"light": "ライト",
"enableAnalytics": "VidBee の改善にご協力ください",
"enableAnalyticsDescription": "匿名の使用状況データを共有することで、アプリの使用状況を把握し、改善の優先順位を付けることができます。",
"embedChapters": "チャプターを埋め込む",
"embedChaptersDescription": "利用可能な場合はファイルにチャプターマーカーを追加",
"embedMetadata": "メタデータを埋め込む",
"embedMetadataDescription": "利用可能な場合はタイトルやアーティストなどのメタデータを書き込む",
"embedSubs": "字幕を埋め込む",
"embedSubsDescription": "字幕を動画ファイルに埋め込むmp4、webm、mkv",
"embedThumbnail": "サムネイルを埋め込む",
"embedThumbnailDescription": "サムネイルをカバーアートとして追加",
"maxConcurrentDownloads": "最大アクティブダウンロード数",
"maxConcurrentDownloadsDescription": "最大同時ダウンロード数",
"none": "なし",
@@ -362,7 +444,6 @@
"normal": "通常",
"worst": "最悪"
},
"openLinkError": "リンクを開けませんでした",
"proxy": "プロキシ",
"proxyDescription": "ネットワークリクエスト用のプロキシサーバー",
"proxyPlaceholder": "http://proxy:port",
@@ -371,8 +452,7 @@
"showMoreFormats": "より多くのフォーマットオプションを表示",
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
"subscriptionDefaults": {
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 24 時間)。"
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
},
"system": "システム",
"theme": "テーマ",
@@ -384,6 +464,120 @@
},
"video": "ビデオ設定"
},
"subscriptions": {
"title": "定期購入",
"subtitle": "{{count}} 件のサブスクリプション{{count、複数、one {} other {s}}}",
"description": "RSS フィードを自動的に監視し、手動作業なしで新しいダウンロードをキューに追加します。",
"defaults": {
"title": "自動化のデフォルト",
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
"downloadDirectory": "ダウンロードディレクトリ",
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",
"onlyLatest": "最新のビデオのみをダウンロードする",
"onlyLatestDescription": "有効にすると、VidBee は古いバックログ項目をスキップし、最新のアップロードのみを取得します。"
},
"add": {
"title": "RSSを追加",
"description": "RSS フィードのリンクを貼り付けます。 \nVidBee はフィードを自動的に検出します。"
},
"fields": {
"url": "フィード URL",
"keywords": "キーワードフィルター (カンマ区切り)",
"tags": "自動タグ",
"customDirectory": "カスタムディレクトリ",
"namingTemplate": "カスタム ファイル名テンプレート (ファイルのみ)",
"onlyLatest": "最新のビデオのみをダウンロードする",
"onlyLatestDescription": "バックログ項目を無視し、このフィードから最新のアップロードのみを取得します。",
"enabled": "有効",
"disabled": "無効",
"onlyLatestShort": "最新のもののみ"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "追加",
"refresh": "リフレッシュ",
"edit": "編集",
"remove": "取り除く",
"save": "変更を保存する",
"selectDirectory": "ブラウズ",
"enable": "有効にする",
"disable": "無効にする"
},
"items": {
"title": "最新のアップロード ({{count}})",
"count": "{{count}} 個のアイテム",
"empty": "最近のフィード項目が見つかりませんでした。",
"status": {
"queued": "キューに入れられました",
"notQueued": "キューに登録されていません",
"pending": "保留中",
"downloading": "ダウンロード中",
"processing": "処理",
"completed": "完了",
"error": "失敗した",
"cancelled": "キャンセル"
},
"fromChannel": "{{channel}} から",
"tooltip": {
"downloadStatus": "ダウンロードステータス: {{status}}",
"downloadPending": "ダウンロードの詳細を待っています...",
"notQueued": "まだダウンロードキューにありません"
},
"actions": {
"open": "ブラウザで開く",
"queue": "ダウンロードキューに追加"
}
},
"labels": {
"subscription": "サブスクリプション",
"unknown": "不明なサブスクリプション",
"noThumbnail": "サムネイルなし"
},
"notifications": {
"directoryError": "ディレクトリピッカーを開けませんでした。",
"missingUrl": "まずチャンネルのリンクを貼り付けてください。",
"created": "サブスクリプションが追加されました",
"createError": "サブスクリプションの追加に失敗しました。",
"refreshStarted": "更新が開始されました",
"removed": "サブスクリプションが削除されました",
"updated": "サブスクリプションが更新されました",
"itemQueued": "ダウンロードキューに追加されました",
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
"queueError": "ダウンロードキューへの追加に失敗しました。",
"openLinkError": "ビデオリンクを開けませんでした。",
"resolveError": "RSS フィード URL を解決できませんでした。",
"duplicateUrl": "このRSSフィードはすでに登録されています。"
},
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
"detecting": "フィードを検出中...",
"latestVideo": "最新の動画: {{title}}",
"lastChecked": "最終チェック日: {{time}}",
"never": "一度もない",
"empty": "まだ購読はありません。\nお気に入りのチャンネルを追加して自動ダウンロードを開始します。",
"edit": {
"title": "{{name}}を編集",
"description": "このフィードのフィルター、タグ、オーバーライドを調整します。"
},
"status": {
"title": "状態",
"up-to-date": "最新の",
"checking": "チェック中",
"failed": "失敗した",
"idle": "アイドル状態",
"tooltip": {
"updatedAt": "更新日: {{time}}"
}
},
"rssHub": {
"title": "RSSHub による自動サブスクリプション",
"description": "VidBee と RSSHub を組み合わせると、さまざまなプラットフォームからの自動サブスクリプションとダウンロードが可能になります。\nセットアップが完了すると、VidBee がバックグラウンドで実行され、最新のビデオとコンテンツが自動的にダウンロードされます。",
"learnMore": "RSSHub について詳しく見る",
"openDocs": "RSSHub ドキュメントを開く",
"hint": "RSS フィード URL をお持ちでない場合は、 \nRSSHub を使用して、YouTube、Twitter、その他数千のプラットフォーム用の RSS フィードを生成します。"
}
},
"sites": {
"homeInlineDescription": "{{sites}}およびその他のサイトをサポートしています。",
"moreDescription": "完全なyt-dlpリストはコミュニティによって継続的に更新されています。",
@@ -468,119 +662,5 @@
},
"popularSection": "主要プラットフォーム",
"viewAll": "サポートされているすべてのサイトを表示"
},
"subscriptions": {
"actions": {
"add": "追加",
"disable": "無効にする",
"edit": "編集",
"enable": "有効にする",
"refresh": "リフレッシュ",
"remove": "取り除く",
"save": "変更を保存する",
"selectDirectory": "ブラウズ"
},
"add": {
"description": "RSS フィードのリンクを貼り付けます。 \nVidBee はフィードを自動的に検出します。",
"title": "RSSを追加"
},
"defaults": {
"checkInterval": "チェック間隔(時間)",
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
"downloadDirectory": "ダウンロードディレクトリ",
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",
"onlyLatest": "最新のビデオのみをダウンロードする",
"onlyLatestDescription": "有効にすると、VidBee は古いバックログ項目をスキップし、最新のアップロードのみを取得します。",
"title": "自動化のデフォルト"
},
"description": "RSS フィードを自動的に監視し、手動作業なしで新しいダウンロードをキューに追加します。",
"detectedFeed": "{{プラットフォーム}} フィードが検出されました -> {{フィード}}",
"detecting": "フィードを検出中...",
"edit": {
"description": "このフィードのフィルター、タグ、オーバーライドを調整します。",
"title": "{{名前}}を編集"
},
"empty": "まだ購読はありません。\nお気に入りのチャンネルを追加して自動ダウンロードを開始します。",
"fields": {
"customDirectory": "カスタムディレクトリ",
"disabled": "無効",
"enabled": "有効",
"keywords": "キーワードフィルター (カンマ区切り)",
"namingTemplate": "カスタム ファイル名テンプレート (ファイルのみ)",
"onlyLatest": "最新のビデオのみをダウンロードする",
"onlyLatestDescription": "バックログ項目を無視し、このフィードから最新のアップロードのみを取得します。",
"onlyLatestShort": "最新のもののみ",
"tags": "自動タグ",
"url": "フィード URL"
},
"items": {
"actions": {
"open": "ブラウザで開く",
"queue": "ダウンロードキューに追加"
},
"count": "{{count}} 個のアイテム",
"empty": "最近のフィード項目が見つかりませんでした。",
"fromChannel": "{{チャンネル}} から",
"status": {
"cancelled": "キャンセル",
"completed": "完了",
"downloading": "ダウンロード中",
"error": "失敗した",
"notQueued": "キューに登録されていません",
"pending": "保留中",
"processing": "処理",
"queued": "キューに入れられました"
},
"title": "最新のアップロード ({{count}})",
"tooltip": {
"downloadPending": "ダウンロードの詳細を待っています...",
"downloadStatus": "ダウンロードステータス: {{ステータス}}",
"notQueued": "まだダウンロードキューにありません"
}
},
"labels": {
"noThumbnail": "サムネイルなし",
"subscription": "サブスクリプション",
"unknown": "不明なサブスクリプション"
},
"lastChecked": "最終チェック日: {{time}}",
"latestVideo": "最新の動画: {{title}}",
"never": "一度もない",
"notifications": {
"createError": "サブスクリプションの追加に失敗しました。",
"created": "サブスクリプションが追加されました",
"directoryError": "ディレクトリピッカーを開けませんでした。",
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
"itemQueued": "ダウンロードキューに追加されました",
"missingUrl": "まずチャンネルのリンクを貼り付けてください。",
"openLinkError": "ビデオリンクを開けませんでした。",
"queueError": "ダウンロードキューへの追加に失敗しました。",
"refreshStarted": "更新が開始されました",
"removed": "サブスクリプションが削除されました",
"resolveError": "RSS フィード URL を解決できませんでした。",
"updated": "サブスクリプションが更新されました"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"rssHub": {
"description": "VidBee と RSSHub を組み合わせると、さまざまなプラットフォームからの自動サブスクリプションとダウンロードが可能になります。\nセットアップが完了すると、VidBee がバックグラウンドで実行され、最新のビデオとコンテンツが自動的にダウンロードされます。",
"hint": "RSS フィード URL をお持ちでない場合は、 \nRSSHub を使用して、YouTube、Twitter、その他数千のプラットフォーム用の RSS フィードを生成します。",
"learnMore": "RSSHub について詳しく見る",
"openDocs": "RSSHub ドキュメントを開く",
"title": "RSSHub による自動サブスクリプション"
},
"status": {
"checking": "チェック中",
"failed": "失敗した",
"idle": "アイドル状態",
"title": "状態",
"tooltip": {
"updatedAt": "更新日: {{time}}"
},
"up-to-date": "最新の"
},
"subtitle": "{{count}} 件のサブスクリプション{{count、複数、one {} other {s}}}",
"title": "定期購入"
}
}

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