Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf7af9b25 | ||
|
|
fe55ac1a79 | ||
|
|
c1d1a1b912 | ||
|
|
9d377dd464 | ||
|
|
be00f5b2a4 | ||
|
|
2d69ce81a4 | ||
|
|
4a9ee1406b | ||
|
|
aed6842319 | ||
|
|
e7957d09ec | ||
|
|
5057d18e09 | ||
|
|
3c1f52b06b | ||
|
|
f061b6b61e | ||
|
|
43cabf65e9 | ||
|
|
da3643bc79 | ||
|
|
976cb82bf9 | ||
|
|
14f80309af | ||
|
|
8acd761189 | ||
|
|
20c6ae6543 | ||
|
|
66f2211d68 | ||
|
|
ac183b4e3c | ||
|
|
877f088136 | ||
|
|
5ee08a3e62 | ||
|
|
137a07cf4e | ||
|
|
21a5adbdf2 | ||
|
|
81f52f86e5 | ||
|
|
c8147cafe3 | ||
|
|
7074f698e5 | ||
|
|
b254bb6dba | ||
|
|
1caca0ea9a | ||
|
|
12ec142a77 | ||
|
|
0136da410d | ||
|
|
4971a5fe8a | ||
|
|
d2806164a2 | ||
|
|
e332ec4ddc | ||
|
|
3b74712f57 | ||
|
|
475e4127c2 | ||
|
|
3041307aa2 | ||
|
|
e2ab8dce60 | ||
|
|
c1806e5321 | ||
|
|
92494966c6 | ||
|
|
f04680b8c2 | ||
|
|
a5b94411fe | ||
|
|
71ae4a4425 | ||
|
|
e8413aefae |
98
.github/workflows/build.yml
vendored
@@ -8,6 +8,17 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
secrets:
|
||||
MAC_CERT_P12_BASE64:
|
||||
required: false
|
||||
MAC_CERT_P12_PASSWORD:
|
||||
required: false
|
||||
APPLE_API_KEY_ID:
|
||||
required: false
|
||||
APPLE_API_ISSUER:
|
||||
required: false
|
||||
APPLE_API_KEY_P8_BASE64:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -68,6 +79,8 @@ jobs:
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
Remove-Item ffmpeg.zip -Force
|
||||
Remove-Item ffmpeg -Recurse -Force
|
||||
|
||||
- name: Download ffmpeg binary (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -85,6 +98,13 @@ jobs:
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_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_bin" ]]; then
|
||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||
exit 1
|
||||
@@ -112,6 +132,7 @@ jobs:
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
rm -rf ffmpeg.tar.xz ffmpeg
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
@@ -124,9 +145,86 @@ 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
|
||||
|
||||
84
.github/workflows/extension-publish.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Publish Extension
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- extension/package.json
|
||||
|
||||
jobs:
|
||||
detect-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.version_check.outputs.changed }}
|
||||
current_version: ${{ steps.version_check.outputs.current_version }}
|
||||
previous_version: ${{ steps.version_check.outputs.previous_version }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if extension version changed
|
||||
id: version_check
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
before_sha="${{ github.event.before }}"
|
||||
current_version=$(node -e "const fs = require('fs'); const v = JSON.parse(fs.readFileSync('extension/package.json', 'utf8')).version; process.stdout.write(v);")
|
||||
previous_version=""
|
||||
if git cat-file -e "${before_sha}:extension/package.json" 2>/dev/null; then
|
||||
previous_version=$(git show "${before_sha}:extension/package.json" | node -e "let data=''; process.stdin.on('data', d => data += d); process.stdin.on('end', () => { const v = JSON.parse(data).version; process.stdout.write(v); });")
|
||||
fi
|
||||
|
||||
echo "current_version=${current_version}" >> "$GITHUB_OUTPUT"
|
||||
echo "previous_version=${previous_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ -n "${previous_version}" && "${current_version}" == "${previous_version}" ]]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
submit:
|
||||
needs: detect-version
|
||||
if: needs.detect-version.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: extension
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: extension/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Zip extensions
|
||||
run: |
|
||||
pnpm zip
|
||||
|
||||
- name: Submit to stores
|
||||
run: |
|
||||
pnpm wxt submit \
|
||||
--chrome-zip .output/*-chrome.zip
|
||||
env:
|
||||
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
CHROME_PUBLISH_TARGET: "default"
|
||||
CHROME_SKIP_SUBMIT_REVIEW: false
|
||||
7
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
needs: [build]
|
||||
@@ -42,3 +43,9 @@ jobs:
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Notify Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
|
||||
run: |
|
||||
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
|
||||
|
||||
2
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.wxt
|
||||
.output
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
1. use pnpm instead of npm
|
||||
2. use pnpm run check after tasks to check code
|
||||
3. Support i18n, only translate the English version of en.json
|
||||
3. Support i18n. When writing business logic, initially only translate the English version of en.json
|
||||
4. use English for comments&console
|
||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
||||
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
|
||||
7. If there is an error when running `pnpm run check:i18n`, please complete the missing corresponding translation files and fields. Ensure the translation is done into the corresponding language, rather than directly copying the English version.
|
||||
|
||||
58
build/after-pack.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
|
||||
|
||||
const findAppBundle = (appOutDir) => {
|
||||
const entries = fs.readdirSync(appOutDir)
|
||||
const app = entries.find((entry) => entry.endsWith('.app'))
|
||||
return app ? path.join(appOutDir, app) : null
|
||||
}
|
||||
|
||||
const resolveSigningIdentity = () =>
|
||||
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
|
||||
|
||||
const signBinary = (targetPath, entitlementsPath) => {
|
||||
const identity = resolveSigningIdentity()
|
||||
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
|
||||
|
||||
if (identity !== '-') {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
}
|
||||
|
||||
args.push(targetPath)
|
||||
execFileSync('codesign', args, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
const appBundle = findAppBundle(context.appOutDir)
|
||||
if (!appBundle) {
|
||||
console.warn('afterPack: No .app bundle found, skipping tool signing.')
|
||||
return
|
||||
}
|
||||
|
||||
const resourcesPath = path.join(
|
||||
appBundle,
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'resources'
|
||||
)
|
||||
|
||||
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
|
||||
|
||||
for (const binary of BINARIES) {
|
||||
const targetPath = path.join(resourcesPath, binary)
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.warn(`afterPack: Missing ${binary}, skipping.`)
|
||||
continue
|
||||
}
|
||||
console.log(`afterPack: Signing ${binary} with entitlements.`)
|
||||
signBinary(targetPath, entitlementsPath)
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "cp -r $CONDUCTOR_ROOT_PATH/resources resources/ && pnpm install",
|
||||
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
|
||||
"run": "pnpm run dev"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ appId: com.vidbee
|
||||
productName: VidBee
|
||||
directories:
|
||||
buildResources: build
|
||||
afterPack: build/after-pack.cjs
|
||||
protocols:
|
||||
- name: VidBee
|
||||
schemes:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!**/.context/**'
|
||||
- '!**/.github/**'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!extension/**'
|
||||
- '!monkey/**'
|
||||
- '!screenshots/**'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
@@ -23,9 +29,10 @@ nsis:
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
identity: null
|
||||
hardenedRuntime: true
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
notarize: true
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
|
||||
29
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.env.*
|
||||
54
extension/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# VidBee Video Downloader Extension
|
||||
|
||||
VidBee Video Downloader is a lightweight browser companion for the VidBee desktop app. It detects the video on your current tab, shows the available formats, and hands the URL to VidBee so the download happens in the desktop app instead of your browser.
|
||||
|
||||
## Why install it?
|
||||
|
||||
- **One-click handoff:** Send the current video page to VidBee without copy-pasting links.
|
||||
- **See formats before downloading:** Preview resolutions, file sizes, and audio-only options in the popup.
|
||||
- **More reliable downloads:** VidBee handles large files and multi-format downloads better than most browsers.
|
||||
- **Works on 1,000+ sites:** The extension uses the same site support as the VidBee app.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads the active tab URL when you open the popup.
|
||||
2. Asks the VidBee desktop app (running locally) to analyze the video.
|
||||
3. Displays the formats it finds and lets you open VidBee to download.
|
||||
|
||||
## Requirements
|
||||
|
||||
- VidBee desktop app installed.
|
||||
- VidBee running while you use the extension.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Open a supported video page.
|
||||
2. Click the VidBee extension icon.
|
||||
3. Review available formats.
|
||||
4. Click **Download with VidBee** to start the download in the desktop app.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Build or package:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm zip
|
||||
```
|
||||
|
||||
Firefox builds:
|
||||
|
||||
```bash
|
||||
pnpm dev:firefox
|
||||
pnpm build:firefox
|
||||
pnpm zip:firefox
|
||||
```
|
||||
|
||||
## Notes on privacy
|
||||
|
||||
The extension only sends the current tab URL to the local VidBee app on `127.0.0.1` and stores temporary results in browser storage for faster reloads.
|
||||
163
extension/assets/content.css
Normal file
@@ -0,0 +1,163 @@
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
203
extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const STATUS_TIMEOUT_MS = 800
|
||||
const INFO_TIMEOUT_MS = 60000
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const pendingRequests = new Map<string, Promise<void>>()
|
||||
const defaultIconPaths = {
|
||||
16: 'icon/16.png',
|
||||
32: 'icon/32.png',
|
||||
48: 'icon/48.png',
|
||||
128: 'icon/128.png'
|
||||
}
|
||||
const loadingIconPaths = {
|
||||
16: 'icon/icon-loading-16.png',
|
||||
32: 'icon/icon-loading-32.png',
|
||||
48: 'icon/icon-loading-48.png',
|
||||
128: 'icon/icon-loading-128.png'
|
||||
}
|
||||
const successIconPaths = {
|
||||
16: 'icon/icon-success-16.png',
|
||||
32: 'icon/icon-success-32.png',
|
||||
48: 'icon/icon-success-48.png',
|
||||
128: 'icon/icon-success-128.png'
|
||||
}
|
||||
|
||||
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
|
||||
const paths =
|
||||
status === 'loading'
|
||||
? loadingIconPaths
|
||||
: status === 'success'
|
||||
? successIconPaths
|
||||
: defaultIconPaths
|
||||
const options = tabId ? { path: paths, tabId } : { path: paths }
|
||||
void browser.action.setIcon(options)
|
||||
}
|
||||
|
||||
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
|
||||
if (!response.ok) {
|
||||
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
|
||||
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
|
||||
const combined = [message, details].filter(Boolean).join('\n\n')
|
||||
throw new Error(combined || `Request failed: ${response.status}`)
|
||||
}
|
||||
return data as T
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('signal is aborted')) {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||
throw new Error('VidBee app not responding on this port.')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
const findAvailablePort = async (): Promise<number | null> => {
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
try {
|
||||
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
|
||||
return port
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
|
||||
const port = await findAvailablePort()
|
||||
if (!port) {
|
||||
throw new Error('VidBee app not found on localhost.')
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
|
||||
if (!tokenResponse.token) {
|
||||
throw new Error('Failed to acquire token from VidBee.')
|
||||
}
|
||||
|
||||
return fetchJson<VideoInfo>(
|
||||
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
|
||||
tokenResponse.token
|
||||
)}`,
|
||||
INFO_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return {}
|
||||
return map
|
||||
}
|
||||
|
||||
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
if (now - entry.fetchedAt > CACHE_TTL_MS) {
|
||||
delete map[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
const cache = map[url]
|
||||
if (!cache) return null
|
||||
return cache
|
||||
}
|
||||
|
||||
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
map[cache.url] = cache
|
||||
await browser.storage.local.set({ videoInfoCacheByUrl: map })
|
||||
}
|
||||
|
||||
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
|
||||
if (pendingRequests.has(url)) {
|
||||
return pendingRequests.get(url) as Promise<void>
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const existing = await loadCache(url)
|
||||
if (existing?.status === 'ready') {
|
||||
setActionIcon('success', tabId)
|
||||
return
|
||||
}
|
||||
|
||||
setActionIcon('loading', tabId)
|
||||
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
|
||||
|
||||
try {
|
||||
const info = await requestVideoInfo(url)
|
||||
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
|
||||
setActionIcon('success', tabId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
|
||||
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
|
||||
setActionIcon('default', tabId)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(url, task)
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
pendingRequests.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
|
||||
if (message.type !== 'video-info:fetch' || !message.url) {
|
||||
return
|
||||
}
|
||||
void fetchAndCache(message.url, sender.tab?.id)
|
||||
})
|
||||
})
|
||||
317
extension/entrypoints/popup/App.css
Normal file
@@ -0,0 +1,317 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--fg-secondary: #757575;
|
||||
--border: #f0f0f0;
|
||||
--accent: #000000;
|
||||
--error: #e00000;
|
||||
--success: #00c853;
|
||||
--warning: #ffd600;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 360px;
|
||||
min-height: 200px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--border);
|
||||
}
|
||||
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
|
||||
.status-dot.ok { background-color: var(--success); }
|
||||
.status-dot.error { background-color: var(--error); }
|
||||
|
||||
.video-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.video-details h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 90px;
|
||||
height: 50px;
|
||||
background: var(--border);
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.formats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.format-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Sticky headers for long lists */
|
||||
.sticky-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
padding: 4px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.format-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.format-table th {
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.format-table td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.format-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-id { width: 50px; color: var(--fg-secondary); }
|
||||
.col-ext { width: 50px; }
|
||||
.col-size { width: 60px; text-align: right; }
|
||||
.format-table th.col-size { text-align: right; }
|
||||
|
||||
.empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
padding: 12px 0;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
background: rgba(224, 0, 0, 0.05);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.primary-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid var(--border);
|
||||
border-top-color: var(--fg);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
473
extension/entrypoints/popup/App.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
const isValidHttpUrl = (value?: string): boolean => {
|
||||
if (!value) return false
|
||||
return value.startsWith('http://') || value.startsWith('https://')
|
||||
}
|
||||
|
||||
const formatDuration = (value?: number): string => {
|
||||
if (!value || value <= 0) return 'Unknown'
|
||||
const totalSeconds = Math.round(value)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
|
||||
}
|
||||
|
||||
const formatBytes = (value?: number): string => {
|
||||
if (!value || value <= 0) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const isVideoFormat = (format: VideoFormat): boolean => {
|
||||
if (format.vcodec && format.vcodec !== 'none') {
|
||||
return true
|
||||
}
|
||||
return Boolean(format.resolution || format.width || format.height)
|
||||
}
|
||||
|
||||
const isAudioFormat = (format: VideoFormat): boolean => {
|
||||
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
type VideoGroup = {
|
||||
label: string
|
||||
height: number
|
||||
formats: VideoFormat[]
|
||||
}
|
||||
|
||||
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return null
|
||||
const cached = map[url]
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
|
||||
return cached
|
||||
}
|
||||
|
||||
const sanitizeError = (error: string): string => {
|
||||
const message = error.toLowerCase()
|
||||
if (
|
||||
message.includes('localhost') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('network') ||
|
||||
message.includes('connect') ||
|
||||
message.includes('failed to request')
|
||||
) {
|
||||
return 'Client connection failed'
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [info, setInfo] = useState<VideoInfo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState<string>('')
|
||||
const [retryTrigger, setRetryTrigger] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const targetState = { url: '' }
|
||||
|
||||
const handleStorageChange = (
|
||||
changes: Record<string, browser.storage.StorageChange>,
|
||||
areaName: string
|
||||
) => {
|
||||
if (!active || areaName !== 'local') return
|
||||
const change = changes.videoInfoCacheByUrl
|
||||
if (!change?.newValue) return
|
||||
|
||||
const map = change.newValue as Record<string, VideoInfoCacheEntry>
|
||||
const next = map[targetState.url]
|
||||
if (!next) return
|
||||
|
||||
if (next.status === 'ready' && next.info) {
|
||||
setInfo(next.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'error' && next.error) {
|
||||
setError(sanitizeError(next.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'pending') {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange)
|
||||
|
||||
const loadInfo = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setInfo(null)
|
||||
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
if (!isValidHttpUrl(tab?.url)) {
|
||||
setError('Please open a valid video page first.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = tab.url as string
|
||||
targetState.url = targetUrl
|
||||
setCurrentUrl(targetUrl)
|
||||
|
||||
const cached = await loadCachedInfo(targetUrl)
|
||||
const shouldBypassCache = retryTrigger > 0
|
||||
if (cached && !shouldBypassCache) {
|
||||
if (cached.status === 'ready' && cached.info) {
|
||||
setInfo(cached.info)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (cached.status === 'error' && cached.error) {
|
||||
setError(sanitizeError(cached.error))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'video-info:fetch',
|
||||
url: targetUrl
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to request video info.'
|
||||
setError(sanitizeError(message))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const latest = await loadCachedInfo(targetUrl)
|
||||
if (latest && latest.status === 'ready' && latest.info) {
|
||||
setInfo(latest.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (latest && latest.status === 'error' && latest.error) {
|
||||
setError(sanitizeError(latest.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadInfo()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
browser.storage.onChanged.removeListener(handleStorageChange)
|
||||
}
|
||||
}, [retryTrigger])
|
||||
|
||||
const formats = useMemo(() => info?.formats ?? [], [info])
|
||||
const groupedFormats = useMemo(() => {
|
||||
const video: VideoFormat[] = []
|
||||
const audio: VideoFormat[] = []
|
||||
const other: VideoFormat[] = []
|
||||
|
||||
for (const format of formats) {
|
||||
if (isVideoFormat(format)) {
|
||||
video.push(format)
|
||||
} else if (isAudioFormat(format)) {
|
||||
audio.push(format)
|
||||
} else {
|
||||
other.push(format)
|
||||
}
|
||||
}
|
||||
|
||||
return { video, audio, other }
|
||||
}, [formats])
|
||||
|
||||
const groupedVideoFormats = useMemo(() => {
|
||||
const raw = groupedFormats.video
|
||||
if (!raw.length) return []
|
||||
|
||||
const groups: Record<number, VideoFormat[]> = {}
|
||||
const noHeight: VideoFormat[] = []
|
||||
|
||||
for (const f of raw) {
|
||||
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
|
||||
const heightVal = h ? Number(h) : 0
|
||||
|
||||
if (heightVal > 0) {
|
||||
if (!groups[heightVal]) groups[heightVal] = []
|
||||
groups[heightVal].push(f)
|
||||
} else {
|
||||
noHeight.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedLabels = Object.keys(groups)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
const result: VideoGroup[] = sortedLabels.map((h) => ({
|
||||
label: `${h}p`,
|
||||
height: h,
|
||||
formats: groups[h].sort((a, b) => {
|
||||
const sa = a.filesize || a.filesize_approx || 0
|
||||
const sb = b.filesize || b.filesize_approx || 0
|
||||
return sb - sa
|
||||
})
|
||||
}))
|
||||
|
||||
if (noHeight.length > 0) {
|
||||
result.push({
|
||||
label: 'Other',
|
||||
height: 0,
|
||||
formats: noHeight
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [groupedFormats.video])
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const clientLaunchDelayMs = 2000
|
||||
|
||||
const openClientApp = async () => {
|
||||
window.location.href = 'vidbee://'
|
||||
await wait(clientLaunchDelayMs)
|
||||
}
|
||||
|
||||
const handleOpenClient = () => {
|
||||
if (!currentUrl) return
|
||||
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||
window.location.href = deepLink
|
||||
}
|
||||
|
||||
const handleOpenClientAndRetry = async () => {
|
||||
await openClientApp()
|
||||
setRetryTrigger((count) => count + 1)
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryTrigger((count) => count + 1)
|
||||
}
|
||||
|
||||
const isInvalidPageError = error === 'Please open a valid video page first.'
|
||||
const isClientConnectionError = Boolean(error?.includes('Client connection failed'))
|
||||
const errorTitle = isInvalidPageError
|
||||
? 'Open a video page'
|
||||
: isClientConnectionError
|
||||
? 'Connect the VidBee app'
|
||||
: 'Something went wrong'
|
||||
const errorDescription = isInvalidPageError
|
||||
? 'Navigate to a supported video page, then try again.'
|
||||
: isClientConnectionError
|
||||
? 'The extension needs the VidBee desktop app to be running.'
|
||||
: 'Try again in a moment.'
|
||||
|
||||
const renderStatus = () => {
|
||||
if (loading)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot loading" /> Working
|
||||
</span>
|
||||
)
|
||||
if (error)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot error" /> Error
|
||||
</span>
|
||||
)
|
||||
if (info)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot ok" /> Ready
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot" /> Idle
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>VidBee</h1>
|
||||
{renderStatus()}
|
||||
</header>
|
||||
{loading && (
|
||||
<div className="loading-container">
|
||||
<div className="spinner" />
|
||||
<div className="loading-text">Analyzing video...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="error-container">
|
||||
<div className="error-header">
|
||||
<h2 className="error-title">{errorTitle}</h2>
|
||||
<p className="error-description">{errorDescription}</p>
|
||||
</div>
|
||||
<div className="error-banner">{error}</div>
|
||||
{isClientConnectionError ? (
|
||||
<div className="action-grid">
|
||||
<div className="action-card">
|
||||
<p className="action-title">Client installed</p>
|
||||
<p className="action-text">
|
||||
Start VidBee and keep it running, then we will retry automatically.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={handleOpenClientAndRetry}
|
||||
>
|
||||
Open Client
|
||||
</button>
|
||||
</div>
|
||||
<div className="action-card">
|
||||
<p className="action-title">Need the app?</p>
|
||||
<p className="action-text">
|
||||
Download VidBee once, install it, then come back here to try again.
|
||||
</p>
|
||||
<a
|
||||
href="https://vidbee.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button"
|
||||
>
|
||||
Download VidBee
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-card">
|
||||
<p className="action-title">Try again</p>
|
||||
<p className="action-text">
|
||||
{isInvalidPageError
|
||||
? 'Open a supported video page, then retry.'
|
||||
: 'Retry after a moment.'}
|
||||
</p>
|
||||
<button type="button" className="secondary-button" onClick={handleRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && info && (
|
||||
<>
|
||||
<section className="video-info">
|
||||
<div className="video-details">
|
||||
<h2>{info.title || 'Untitled video'}</h2>
|
||||
<div className="meta-row">
|
||||
<span>{formatDuration(info.duration)}</span>
|
||||
<span>•</span>
|
||||
<span>{formats.length} formats</span>
|
||||
</div>
|
||||
</div>
|
||||
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
|
||||
</section>
|
||||
|
||||
<button type="button" className="primary-button" onClick={handleOpenClient}>
|
||||
Download with VidBee
|
||||
</button>
|
||||
|
||||
<section className="formats-section">
|
||||
{groupedVideoFormats.map((group) => (
|
||||
<div className="format-group" key={group.label}>
|
||||
<div className="group-title sticky-title">{group.label}</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.formats.map((f) => (
|
||||
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
|
||||
<div className="empty-state">No compatible formats.</div>
|
||||
)}
|
||||
|
||||
{groupedFormats.audio.length > 0 && (
|
||||
<div className="format-group">
|
||||
<div className="group-title">Audio Only</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedFormats.audio.map((f) => (
|
||||
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
13
extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Popup Title</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
extension/entrypoints/popup/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (root) {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
28
extension/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vidbee-extension",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"typescript": "^5.9.3",
|
||||
"wxt": "^0.20.6"
|
||||
}
|
||||
}
|
||||
3541
extension/pnpm-lock.yaml
generated
Normal file
14
extension/public/_locales/en/messages.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "VidBee Video Downloader"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Download videos from over 1,000 websites with VidBee."
|
||||
},
|
||||
"downloadWithVidBee": {
|
||||
"message": "Download with VidBee"
|
||||
},
|
||||
"hideButton": {
|
||||
"message": "Hide"
|
||||
}
|
||||
}
|
||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/public/icon/48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-loading-128.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
extension/public/icon/icon-loading-16.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
extension/public/icon/icon-loading-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-loading-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-success-128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
extension/public/icon/icon-success-16.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
extension/public/icon/icon-success-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-success-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
13
extension/wxt.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'wxt'
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
manifest: {
|
||||
name: '__MSG_extensionName__',
|
||||
description: '__MSG_extensionDescription__',
|
||||
default_locale: 'en',
|
||||
host_permissions: ['http://127.0.0.1/*'],
|
||||
permissions: ['activeTab', 'storage']
|
||||
}
|
||||
})
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.12",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
"homepage": "https://github.com/nexmoe/vidbee",
|
||||
"scripts": {
|
||||
"check": "biome check --write . && pnpm run typecheck",
|
||||
"check": "pnpm run check:i18n && biome check --write . && pnpm run typecheck",
|
||||
"check:i18n": "node scripts/check-locales.js",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
@@ -33,7 +34,9 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
@@ -47,6 +50,7 @@
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"electron-ipc-decorator": "^0.2.0",
|
||||
|
||||
94
pnpm-lock.yaml
generated
@@ -38,9 +38,15 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -80,6 +86,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
dayjs:
|
||||
specifier: ^1.11.18
|
||||
version: 1.11.18
|
||||
@@ -999,6 +1008,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -1064,6 +1086,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8':
|
||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
@@ -1896,6 +1931,12 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -4481,6 +4522,29 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -4538,6 +4602,24 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -5429,6 +5511,18 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
100
scripts/check-locales.js
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const localesDir = path.join(__dirname, '..', 'src', 'renderer', 'src', 'locales')
|
||||
const baseLocaleFile = 'en.json'
|
||||
|
||||
const readJson = (filePath) => {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error(`ERROR: Failed to read ${filePath}`)
|
||||
console.error(String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const collectLeafKeys = (value, prefix = '', keys = new Set()) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const next = prefix ? `${prefix}.${key}` : key
|
||||
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
||||
collectLeafKeys(child, next, keys)
|
||||
} else {
|
||||
keys.add(next)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
keys.add(prefix)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
console.error(`ERROR: Locales directory not found: ${localesDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const localeFiles = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.sort()
|
||||
|
||||
if (!localeFiles.includes(baseLocaleFile)) {
|
||||
console.error(`ERROR: Base locale file not found: ${baseLocaleFile}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseLocalePath = path.join(localesDir, baseLocaleFile)
|
||||
const baseLocaleData = readJson(baseLocalePath)
|
||||
const baseKeys = collectLeafKeys(baseLocaleData)
|
||||
|
||||
let hasMissing = false
|
||||
let hasExtra = false
|
||||
|
||||
for (const file of localeFiles) {
|
||||
if (file === baseLocaleFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
const localePath = path.join(localesDir, file)
|
||||
const localeData = readJson(localePath)
|
||||
const localeKeys = collectLeafKeys(localeData)
|
||||
|
||||
const missing = [...baseKeys].filter((key) => !localeKeys.has(key))
|
||||
const extra = [...localeKeys].filter((key) => !baseKeys.has(key))
|
||||
|
||||
if (missing.length > 0) {
|
||||
hasMissing = true
|
||||
console.error(`ERROR: Missing keys in ${file}`)
|
||||
for (const key of missing) {
|
||||
console.error(` - ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (extra.length > 0) {
|
||||
hasExtra = true
|
||||
console.warn(`WARN: Extra keys in ${file}`)
|
||||
for (const key of extra) {
|
||||
console.warn(` - ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMissing) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (hasExtra) {
|
||||
console.log('INFO: No missing keys, but extra keys were found.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log('OK: All locale files include every key from en.json.')
|
||||
@@ -8,7 +8,7 @@
|
||||
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')
|
||||
|
||||
@@ -33,7 +33,7 @@ const PLATFORM_CONFIG = {
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const PLATFORM_CONFIG = {
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
@@ -104,40 +104,101 @@ function ensureDir(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeUnlink(filePath) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadHeaders(url) {
|
||||
const headers = {
|
||||
'User-Agent': 'vidbee-setup',
|
||||
Accept: '*/*'
|
||||
}
|
||||
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
|
||||
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
let downloadedBytes = 0
|
||||
|
||||
protocol
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
reject(err)
|
||||
safeUnlink(dest)
|
||||
const redirectUrl = response.headers.location
|
||||
if (!redirectUrl) {
|
||||
return reject(new Error(`Redirect without location for ${url}`))
|
||||
}
|
||||
log(`Redirected to ${redirectUrl}`, 'info')
|
||||
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
const contentLength = response.headers['content-length']
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
return reject(
|
||||
new Error(
|
||||
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
})
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
log(
|
||||
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
|
||||
downloadedBytes ? 'success' : 'warn'
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy(new Error('Download timeout'))
|
||||
})
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
log(`Download error for ${url}: ${err.message}`, 'error')
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||
let lastError
|
||||
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
|
||||
await downloadFile(url, dest)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
safeUnlink(dest)
|
||||
if (attempt < retries) {
|
||||
const backoff = delayMs * attempt
|
||||
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
@@ -258,6 +319,37 @@ 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) {
|
||||
const result = spawnSync(filePath, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { ok: false, message: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
return { ok: false, message: output || `exit code ${result.status}` }
|
||||
}
|
||||
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
|
||||
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
|
||||
}
|
||||
|
||||
function getDenoAssetName(platform, arch) {
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') {
|
||||
@@ -290,6 +382,10 @@ async function downloadYtDlp(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -299,9 +395,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)) {
|
||||
@@ -316,6 +417,10 @@ async function downloadFfmpegWindows(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -342,7 +447,7 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -352,6 +457,11 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
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
|
||||
@@ -376,6 +486,10 @@ async function downloadFfmpegMac(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -397,7 +511,7 @@ async function downloadFfmpegMac(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -408,6 +522,11 @@ async function downloadFfmpegMac(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
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
|
||||
@@ -425,6 +544,10 @@ async function downloadFfmpegLinux(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -451,7 +574,7 @@ async function downloadFfmpegLinux(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempTar)
|
||||
await downloadFileWithRetry(downloadUrl, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
@@ -462,6 +585,11 @@ async function downloadFfmpegLinux(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -488,6 +616,10 @@ async function downloadDenoRuntime() {
|
||||
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${outputName} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -498,7 +630,7 @@ async function downloadDenoRuntime() {
|
||||
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||
log('Extracting Deno runtime...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -509,6 +641,11 @@ async function downloadDenoRuntime() {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${outputName} successfully`, 'success')
|
||||
|
||||
fs.unlinkSync(tempZip)
|
||||
|
||||
@@ -20,22 +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 && audioFormat === '') {
|
||||
return format
|
||||
}
|
||||
|
||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
if (format && (format.includes('/') || format.includes('+') || format.includes('['))) {
|
||||
return format
|
||||
}
|
||||
|
||||
if (audioFormatIds.length > 0) {
|
||||
const baseVideo = format && format !== 'best' ? format : 'bestvideo*'
|
||||
return `${baseVideo}+${audioFormatIds.join('+')}`
|
||||
}
|
||||
|
||||
if (!format || format === 'best') {
|
||||
if (audioFormat === 'none') {
|
||||
return 'bestvideo+none'
|
||||
}
|
||||
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}`
|
||||
}
|
||||
@@ -62,13 +68,29 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
||||
return format
|
||||
}
|
||||
|
||||
const isBilibiliUrl = (url: string): boolean => {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
return (
|
||||
host === 'bilibili.com' ||
|
||||
host.endsWith('.bilibili.com') ||
|
||||
host === 'b23.tv' ||
|
||||
host.endsWith('.b23.tv') ||
|
||||
host === 'bili.tv' ||
|
||||
host.endsWith('.bili.tv')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const buildDownloadArgs = (
|
||||
options: DownloadOptions,
|
||||
downloadPath: string,
|
||||
settings: AppSettings,
|
||||
jsRuntimeArgs: string[] = []
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
const args: string[] = ['--no-playlist', '--no-mtime']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
@@ -77,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
|
||||
@@ -95,10 +118,30 @@ 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')
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
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
|
||||
|
||||
@@ -194,7 +194,7 @@ export const resolveSelectedFormat = (
|
||||
return selectVideoFormatForPreset(videoFormats, preset)
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
if (options.type === 'audio') {
|
||||
const audioFormats = formats.filter(
|
||||
(format) =>
|
||||
!!format.acodec &&
|
||||
|
||||
@@ -2,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,12 +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', {
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
@@ -379,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
|
||||
@@ -402,6 +457,8 @@ app.whenReady().then(async () => {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
await startExtensionApiServer()
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
@@ -449,6 +506,7 @@ app.on('window-all-closed', () => {
|
||||
// Cleanup tray on quit
|
||||
app.on('will-quit', () => {
|
||||
destroyTray()
|
||||
void stopExtensionApiServer()
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
|
||||
import { AppService } from './services/app-service'
|
||||
import { BrowserCookiesService } from './services/browser-cookies-service'
|
||||
import { DownloadService } from './services/download-service'
|
||||
import { FileSystemService } from './services/file-system-service'
|
||||
import { HistoryService } from './services/history-service'
|
||||
@@ -12,6 +13,7 @@ import { WindowService } from './services/window-service'
|
||||
// Create services with automatic type inference
|
||||
export const services = createServices([
|
||||
AppService,
|
||||
BrowserCookiesService,
|
||||
DownloadService,
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
|
||||
@@ -16,6 +16,37 @@ class AppService extends IpcService {
|
||||
return os.platform()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getOsVersion(_context: IpcContext): string {
|
||||
const platform = os.platform()
|
||||
const platformLabel =
|
||||
platform === 'darwin'
|
||||
? 'macOS'
|
||||
: platform === 'win32'
|
||||
? 'Windows'
|
||||
: platform === 'linux'
|
||||
? 'Linux'
|
||||
: platform
|
||||
const systemVersion =
|
||||
typeof (process as { getSystemVersion?: () => string }).getSystemVersion === 'function'
|
||||
? (process as { getSystemVersion: () => string }).getSystemVersion()
|
||||
: typeof os.version === 'function'
|
||||
? os.version()
|
||||
: os.release()
|
||||
|
||||
if (platform === 'win32') {
|
||||
const buildToken = systemVersion.split('.').at(-1) ?? ''
|
||||
const buildNumber = Number.parseInt(buildToken, 10)
|
||||
const windowsName =
|
||||
Number.isFinite(buildNumber) && buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
|
||||
return Number.isFinite(buildNumber)
|
||||
? `${windowsName} (build ${buildNumber})`
|
||||
: `${platformLabel} ${systemVersion}`.trim()
|
||||
}
|
||||
|
||||
return `${platformLabel} ${systemVersion}`.trim()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
quit(_context: IpcContext): void {
|
||||
app.quit()
|
||||
|
||||
270
src/main/ipc/services/browser-cookies-service.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { resolvePathWithHome } from '../../utils/path-helpers'
|
||||
|
||||
class BrowserCookiesService extends IpcService {
|
||||
static readonly groupName = 'browserCookies'
|
||||
|
||||
private buildValidationResult(valid: boolean, reason?: string) {
|
||||
if (valid) {
|
||||
return { valid }
|
||||
}
|
||||
return { valid, reason }
|
||||
}
|
||||
|
||||
private isDirectory(target: string): boolean {
|
||||
try {
|
||||
return fs.statSync(target).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private pickFirstDirectory(paths: string[]): string {
|
||||
for (const candidate of paths) {
|
||||
if (this.isDirectory(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private normalizeProfileInput(value: string): string {
|
||||
return value.trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
|
||||
if (platform === 'win32') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [
|
||||
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
|
||||
]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
|
||||
]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
|
||||
]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [
|
||||
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
|
||||
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
|
||||
]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
|
||||
}
|
||||
if (browser === 'safari') {
|
||||
return [path.join(homeDir, 'Library', 'Safari')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (browser === 'edge') {
|
||||
return [path.join(homeDir, '.config', 'microsoft-edge')]
|
||||
}
|
||||
if (browser === 'chrome') {
|
||||
return [path.join(homeDir, '.config', 'google-chrome')]
|
||||
}
|
||||
if (browser === 'chromium') {
|
||||
return [path.join(homeDir, '.config', 'chromium')]
|
||||
}
|
||||
if (browser === 'brave') {
|
||||
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
|
||||
}
|
||||
if (browser === 'vivaldi') {
|
||||
return [path.join(homeDir, '.config', 'vivaldi')]
|
||||
}
|
||||
if (browser === 'whale') {
|
||||
return [path.join(homeDir, '.config', 'naver-whale')]
|
||||
}
|
||||
if (browser === 'opera') {
|
||||
return [path.join(homeDir, '.config', 'opera')]
|
||||
}
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'freebsd') {
|
||||
if (browser === 'firefox') {
|
||||
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
|
||||
const base = baseDirs[0]
|
||||
if (!base) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
|
||||
return base
|
||||
}
|
||||
|
||||
return path.join(base, 'Default')
|
||||
}
|
||||
|
||||
private findFirefoxProfilePath(profilesDir: string): string {
|
||||
if (!this.isDirectory(profilesDir)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(profilesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const preferred =
|
||||
entries.find((name) => name.endsWith('.default-release')) ??
|
||||
entries.find((name) => name.endsWith('.default')) ??
|
||||
entries[0]
|
||||
|
||||
return preferred ? path.join(profilesDir, preferred) : ''
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getBrowserProfilePath(_context: IpcContext, browser: string): string {
|
||||
if (!browser || browser === 'none') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
const platform = os.platform()
|
||||
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
|
||||
|
||||
if (browser === 'firefox') {
|
||||
const profilesDir = baseDirs[0]
|
||||
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
|
||||
return profilePath || fallbackPath
|
||||
}
|
||||
|
||||
if (browser === 'safari') {
|
||||
const safariPath = baseDirs[0]
|
||||
if (safariPath && this.isDirectory(safariPath)) {
|
||||
return safariPath
|
||||
}
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
if (baseDirs.length === 0) {
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
let detectedPath = ''
|
||||
for (const baseDir of baseDirs) {
|
||||
if (!baseDir) {
|
||||
continue
|
||||
}
|
||||
const candidates =
|
||||
browser === 'opera'
|
||||
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||
detectedPath = this.pickFirstDirectory(candidates)
|
||||
if (detectedPath) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return detectedPath || fallbackPath
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
validateBrowserProfilePath(
|
||||
_context: IpcContext,
|
||||
browser: string,
|
||||
profilePath: string
|
||||
): { valid: boolean; reason?: string } {
|
||||
if (!browser || browser === 'none') {
|
||||
return this.buildValidationResult(false, 'browserUnsupported')
|
||||
}
|
||||
|
||||
const normalizedInput = this.normalizeProfileInput(profilePath)
|
||||
if (!normalizedInput) {
|
||||
return this.buildValidationResult(false, 'empty')
|
||||
}
|
||||
|
||||
const resolvedInput = resolvePathWithHome(normalizedInput)
|
||||
if (resolvedInput && this.isDirectory(resolvedInput)) {
|
||||
return this.buildValidationResult(true)
|
||||
}
|
||||
|
||||
const looksLikePath =
|
||||
resolvedInput &&
|
||||
(path.isAbsolute(resolvedInput) ||
|
||||
resolvedInput.includes('/') ||
|
||||
resolvedInput.includes('\\'))
|
||||
if (looksLikePath) {
|
||||
return this.buildValidationResult(false, 'pathNotFound')
|
||||
}
|
||||
|
||||
const platform = os.platform()
|
||||
const homeDir = os.homedir()
|
||||
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||
if (baseDirs.length === 0) {
|
||||
return this.buildValidationResult(false, 'browserUnsupported')
|
||||
}
|
||||
for (const baseDir of baseDirs) {
|
||||
if (!baseDir) {
|
||||
continue
|
||||
}
|
||||
const candidate = path.join(baseDir, normalizedInput)
|
||||
if (this.isDirectory(candidate)) {
|
||||
return this.buildValidationResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildValidationResult(false, 'profileNotFound')
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserCookiesService }
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
VideoInfo,
|
||||
VideoInfoCommandResult
|
||||
} from '../../../shared/types'
|
||||
import { downloadEngine } from '../../lib/download-engine'
|
||||
|
||||
@@ -17,6 +18,14 @@ class DownloadService extends IpcService {
|
||||
return downloadEngine.getVideoInfo(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getVideoInfoWithCommand(
|
||||
_context: IpcContext,
|
||||
url: string
|
||||
): Promise<VideoInfoCommandResult> {
|
||||
return downloadEngine.getVideoInfoWithCommand(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
|
||||
return downloadEngine.getPlaylistInfo(url)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const downloadHistoryTable = sqliteTable('download_history', {
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
error: text('error'),
|
||||
ytDlpCommand: text('yt_dlp_command'),
|
||||
description: text('description'),
|
||||
channel: text('channel'),
|
||||
uploader: text('uploader'),
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
VideoInfo,
|
||||
VideoInfoCommandResult
|
||||
} from '../../shared/types'
|
||||
import {
|
||||
buildDownloadArgs,
|
||||
@@ -36,6 +37,19 @@ 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 ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
@@ -67,6 +81,59 @@ const isLikelyChannelUrl = (url: string): boolean => {
|
||||
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
|
||||
}
|
||||
|
||||
const clampPercent = (value?: number): number => {
|
||||
const normalized = typeof value === 'number' ? value : 0
|
||||
if (Number.isNaN(normalized)) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, Math.max(0, normalized))
|
||||
}
|
||||
|
||||
const estimateProgressParts = (options: DownloadOptions): number => {
|
||||
if (options.type === 'audio') {
|
||||
return 1
|
||||
}
|
||||
|
||||
const audioFormatCount = options.audioFormatIds?.filter((id) => id.trim() !== '').length ?? 0
|
||||
if (audioFormatCount > 0) {
|
||||
return 1 + audioFormatCount
|
||||
}
|
||||
|
||||
const selector = options.format?.trim()
|
||||
if (!selector) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const primary = selector.split('/')[0]?.trim()
|
||||
if (!primary) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const parts = primary
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (parts.some((part) => part === 'none')) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return parts.length
|
||||
}
|
||||
|
||||
const isMuxedFormat = (format?: VideoFormat): boolean => {
|
||||
if (!format) {
|
||||
return false
|
||||
}
|
||||
const hasVideo = !!format.vcodec && format.vcodec !== 'none'
|
||||
const hasAudio = !!format.acodec && format.acodec !== 'none'
|
||||
return hasVideo && hasAudio
|
||||
}
|
||||
|
||||
const resolveAutoPlaylistDownloadPath = (
|
||||
basePath: string,
|
||||
info: PlaylistInfo,
|
||||
@@ -152,6 +219,44 @@ const appendJsRuntimeArgs = (args: string[]): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsManager.getAll>) => {
|
||||
const args = ['-j', '--no-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
@@ -170,39 +275,7 @@ class DownloadEngine extends EventEmitter {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const args = ['-j', '--no-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
const args = buildVideoInfoArgs(url, settings)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = ytdlp.exec(args)
|
||||
@@ -268,6 +341,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()
|
||||
@@ -578,6 +735,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 {
|
||||
@@ -591,6 +751,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,
|
||||
@@ -732,6 +900,10 @@ class DownloadEngine extends EventEmitter {
|
||||
args.push('--ffmpeg-location', ffmpegPath)
|
||||
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
|
||||
@@ -770,8 +942,23 @@ 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 || '',
|
||||
@@ -829,7 +1016,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)
|
||||
@@ -1051,6 +1239,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
|
||||
}
|
||||
@@ -1115,6 +1306,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,10 @@ import log from 'electron-log/main'
|
||||
import Parser from 'rss-parser'
|
||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
|
||||
import {
|
||||
buildAudioFormatPreference,
|
||||
buildVideoFormatPreference
|
||||
} from '../../shared/utils/format-preferences'
|
||||
import { settingsManager } from '../settings'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { historyManager } from './history-manager'
|
||||
@@ -19,6 +23,11 @@ type ParserItem = {
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
content?: string
|
||||
contentSnippet?: string
|
||||
contentEncoded?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
@@ -41,24 +50,19 @@ type FeedItem = {
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
const parser = new Parser<Record<string, never>, ParserItem>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
['enclosure', 'enclosure'],
|
||||
['content:encoded', 'contentEncoded'],
|
||||
['description', 'description']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||
return `sub_${base}`
|
||||
@@ -167,7 +171,7 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
const intervalHours = 3 // Default check interval: 3 hours
|
||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||
this.timer = setTimeout(() => {
|
||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||
@@ -240,13 +244,18 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
const coverUrl = this.resolveSubscriptionCover(
|
||||
feed,
|
||||
normalizedItems,
|
||||
feedItems as ParserItem[]
|
||||
)
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
coverUrl: coverUrl ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
@@ -366,6 +375,74 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
// Try to parse an image from HTML fields
|
||||
const htmlCandidates = [
|
||||
item.content,
|
||||
item.contentEncoded,
|
||||
item.description,
|
||||
item.summary,
|
||||
item.contentSnippet
|
||||
]
|
||||
for (const html of htmlCandidates) {
|
||||
const imageUrl = this.extractImageFromHtml(html)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private resolveSubscriptionCover(
|
||||
feed: Parser.Output<ParserItem>,
|
||||
items: FeedItem[],
|
||||
rawItems: ParserItem[]
|
||||
): string | undefined {
|
||||
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
|
||||
if (feedImageUrl) {
|
||||
return feedImageUrl
|
||||
}
|
||||
|
||||
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
|
||||
if (itunesImageUrl) {
|
||||
return itunesImageUrl
|
||||
}
|
||||
|
||||
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
|
||||
if (itemThumbnail) {
|
||||
return itemThumbnail
|
||||
}
|
||||
|
||||
for (const item of rawItems) {
|
||||
const thumbnail = this.resolveThumbnail(item)
|
||||
if (thumbnail) {
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private extractImageFromHtml(html?: string): string | undefined {
|
||||
if (!html) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const srcMatch = html.match(
|
||||
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
|
||||
)
|
||||
if (srcMatch?.[2]) {
|
||||
return srcMatch[2]
|
||||
}
|
||||
|
||||
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
|
||||
if (srcsetMatch?.[2]) {
|
||||
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
|
||||
if (firstCandidate) {
|
||||
return firstCandidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -408,6 +485,11 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||
const namingTemplate =
|
||||
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
const downloadType = settings.oneClickDownloadType ?? 'video'
|
||||
const formatPreference =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
@@ -415,7 +497,8 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
type: downloadType,
|
||||
format: formatPreference,
|
||||
customDownloadPath: downloadDirectory,
|
||||
customFilenameTemplate: namingTemplate,
|
||||
tags,
|
||||
|
||||
191
src/main/local-api.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import crypto from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const TOKEN_TTL_MS = 60_000
|
||||
|
||||
type TokenRecord = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let server: http.Server | null = null
|
||||
let serverPort: number | null = null
|
||||
const tokens = new Map<string, TokenRecord>()
|
||||
|
||||
const isLoopbackAddress = (address?: string | null): boolean => {
|
||||
if (!address) return false
|
||||
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
||||
}
|
||||
|
||||
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const writeEmpty = (res: http.ServerResponse, status: number): void => {
|
||||
res.writeHead(status, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
|
||||
const issueToken = (): string => {
|
||||
const token = crypto.randomBytes(16).toString('hex')
|
||||
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
const consumeToken = (token?: string | null): boolean => {
|
||||
if (!token) return false
|
||||
const record = tokens.get(token)
|
||||
if (!record) return false
|
||||
if (Date.now() > record.expiresAt) {
|
||||
tokens.delete(token)
|
||||
return false
|
||||
}
|
||||
tokens.delete(token)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
||||
writeJson(res, 403, { error: 'Forbidden' })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
writeEmpty(res, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.url) {
|
||||
writeJson(res, 400, { error: 'Missing URL' })
|
||||
return
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, 'http://127.0.0.1')
|
||||
const pathname = requestUrl.pathname
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
writeJson(res, 405, { error: 'Method not allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/token') {
|
||||
const token = issueToken()
|
||||
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/video-info') {
|
||||
const token = requestUrl.searchParams.get('token')
|
||||
if (!consumeToken(token)) {
|
||||
writeJson(res, 401, { error: 'Invalid token' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = requestUrl.searchParams.get('url')
|
||||
if (!targetUrl || !targetUrl.trim()) {
|
||||
writeJson(res, 400, { error: 'Missing url' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
|
||||
writeJson(res, 200, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
formats: info.formats ?? []
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
const details =
|
||||
error instanceof Error
|
||||
? error.stack
|
||||
: typeof error === 'object' && error && 'stderr' in error
|
||||
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||
: undefined
|
||||
writeJson(res, 500, { error: message, details })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/status') {
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unhandled request error'
|
||||
writeJson(res, 500, { error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const startServerOnPort = (port: number): Promise<http.Server> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
void handleRequest(req, res)
|
||||
})
|
||||
|
||||
httpServer.once('error', (error) => {
|
||||
httpServer.close()
|
||||
reject(error)
|
||||
})
|
||||
|
||||
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
|
||||
})
|
||||
|
||||
export async function startExtensionApiServer(): Promise<number | null> {
|
||||
if (server && serverPort) {
|
||||
return serverPort
|
||||
}
|
||||
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
try {
|
||||
server = await startServerOnPort(port)
|
||||
const address = server.address() as AddressInfo | null
|
||||
serverPort = address?.port ?? port
|
||||
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
|
||||
return serverPort
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code !== 'EADDRINUSE') {
|
||||
log.warn('Extension API failed to start on port:', port, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function stopExtensionApiServer(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server?.close(() => resolve())
|
||||
})
|
||||
|
||||
server = null
|
||||
serverPort = null
|
||||
tokens.clear()
|
||||
}
|
||||
@@ -20,9 +20,7 @@ const ensureDirectoryExists = (dir: string) => {
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
return path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
@@ -53,7 +51,11 @@ class SettingsManager {
|
||||
}
|
||||
|
||||
getAll(): AppSettings {
|
||||
return this.store.store
|
||||
return {
|
||||
...defaultSettings,
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH,
|
||||
...this.store.store
|
||||
}
|
||||
}
|
||||
|
||||
setAll(settings: Partial<AppSettings>): void {
|
||||
@@ -71,7 +73,6 @@ class SettingsManager {
|
||||
...defaultSettings,
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
@@ -84,7 +85,6 @@ class SettingsManager {
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||
}
|
||||
|
||||
@@ -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,7 @@ 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 { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
@@ -16,6 +16,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 +52,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()
|
||||
@@ -174,16 +177,46 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
resetDownloadState()
|
||||
setUpdateReady({
|
||||
ready: true,
|
||||
version: info.version
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: info.version
|
||||
})
|
||||
|
||||
const versionLabel = info?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: i18n.t('about.notifications.updateDownloaded')
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: i18n.t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateError = (rawMessage: unknown) => {
|
||||
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
||||
resetDownloadState()
|
||||
|
||||
const errorMessage = message || t('about.notifications.unknownErrorFallback')
|
||||
toast.error(t('about.notifications.updateError', { error: errorMessage }))
|
||||
const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')
|
||||
toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))
|
||||
}
|
||||
|
||||
const handleDownloadProgress = (rawProgress: unknown) => {
|
||||
@@ -193,37 +226,20 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||
const payload = (rawPayload ?? {}) as { version?: string }
|
||||
const versionLabel = payload?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only listen to update events that should be shown globally
|
||||
// update:available is handled in About page only
|
||||
// update:available shows a visual indicator in the sidebar
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.on('update:error', handleUpdateError)
|
||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [t])
|
||||
}, [i18n, setUpdateAvailable, setUpdateReady])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
@@ -239,28 +255,23 @@ function AppContent() {
|
||||
{/* Custom Title Bar */}
|
||||
<TitleBar platform={platform} />
|
||||
|
||||
<ScrollArea
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex-1 h-full overflow-y-auto overflow-x-hidden">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Toaster richColors={true} />
|
||||
@@ -270,11 +281,13 @@ function AppContent() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply text-foreground;
|
||||
@apply text-foreground select-none;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable='true'],
|
||||
[contenteditable=''] {
|
||||
@apply select-text;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
248
src/renderer/src/components/download/PlaylistDownload.tsx
Normal 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">
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ 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="space-y-2 rounded-md bg-muted/30 px-2.5 py-2 mx-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
753
src/renderer/src/components/download/SingleVideoDownload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -398,8 +399,8 @@ export function UnifiedDownloadHistory({
|
||||
}, [filteredRecords])
|
||||
|
||||
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) => {
|
||||
@@ -437,47 +438,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">
|
||||
|
||||
152
src/renderer/src/components/error/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
200
src/renderer/src/components/error/ErrorPage.tsx
Normal 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')
|
||||
}
|
||||
213
src/renderer/src/components/feedback/FeedbackLinks.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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'
|
||||
|
||||
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'
|
||||
|
||||
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}: ${sourceUrl}`)
|
||||
}
|
||||
if (ytDlpCommand) {
|
||||
lines.push(`${commandLabel}: ${ytDlpCommand}`)
|
||||
}
|
||||
lines.push(`${errorLabel}: ${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
|
||||
}
|
||||
|
||||
export const FeedbackLinkButtons = ({
|
||||
error,
|
||||
sourceUrl,
|
||||
issueTitle = '[bug]: ',
|
||||
includeAppInfo = false,
|
||||
appInfo,
|
||||
buttonVariant = 'outline',
|
||||
buttonSize = 'sm',
|
||||
buttonClassName,
|
||||
iconClassName,
|
||||
onLinkClick,
|
||||
ytDlpCommand
|
||||
}: 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 ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
||||
const resolvedSourceUrl = sourceUrl?.trim() || undefined
|
||||
const normalizedCommand = ytDlpCommand?.trim() || undefined
|
||||
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
|
||||
const issueLogs = shouldIncludeLogs
|
||||
? clampText(
|
||||
buildIssueLogs(
|
||||
issueError,
|
||||
resolvedSourceUrl,
|
||||
normalizedCommand,
|
||||
FEEDBACK_SOURCE_LABEL,
|
||||
FEEDBACK_ERROR_LABEL,
|
||||
FEEDBACK_COMMAND_LABEL
|
||||
),
|
||||
800
|
||||
)
|
||||
: null
|
||||
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
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)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
|
||||
},
|
||||
{
|
||||
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])
|
||||
|
||||
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={onLinkClick}>
|
||||
<Icon className={iconClassName} />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
39
src/renderer/src/components/ui/popover.tsx
Normal 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 }
|
||||
42
src/renderer/src/components/ui/radio-group.tsx
Normal 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 }
|
||||
@@ -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])
|
||||
|
||||
@@ -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">
|
||||
|
||||
89
src/renderer/src/components/ui/table.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,317 +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 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)
|
||||
const isHlsFormat = (format: VideoFormat) =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
|
||||
const videoCandidates = formats.filter(
|
||||
(format) => isVideoFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
const audioCandidates = formats.filter(
|
||||
(format) => isAudioFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
|
||||
const videos =
|
||||
videoCandidates.length > 0
|
||||
? videoCandidates
|
||||
: formats.filter((format) => isVideoFormat(format))
|
||||
const audios =
|
||||
audioCandidates.length > 0
|
||||
? audioCandidates
|
||||
: formats.filter((format) => isAudioFormat(format))
|
||||
|
||||
// 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')
|
||||
|
||||
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
|
||||
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
finalVideos.sort(sortVideoFormatsByQuality)
|
||||
finalAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
// Auto-select best format based on preferences
|
||||
if (finalVideos.length > 0 && !selectedVideo) {
|
||||
const preferred = pickVideoFormatForPreset(finalVideos, settings.oneClickQuality)
|
||||
if (preferred) {
|
||||
setSelectedVideo(preferred.format_id)
|
||||
onVideoFormatChange?.(preferred.format_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (finalAudios.length > 0 && !selectedAudio) {
|
||||
const best = finalAudios[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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
20
src/renderer/src/lib/logger.ts
Normal 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')
|
||||
}
|
||||
@@ -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,30 @@
|
||||
"formatNote": "ملاحظة التنسيق",
|
||||
"protocol": "البروتوكول",
|
||||
"subscription": "الاشتراك"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "حدث خطأ ما",
|
||||
"description": "حدث خطأ غير متوقع. يرجى إعادة تحميل التطبيق أو الإبلاغ عن هذه المشكلة إذا استمرت.",
|
||||
"message": "رسالة الخطأ",
|
||||
"unknownError": "حدث خطأ غير معروف",
|
||||
"goHome": "العودة إلى الصفحة الرئيسية",
|
||||
"reload": "إعادة تحميل التطبيق",
|
||||
"copyReport": "نسخ تقرير الخطأ",
|
||||
"copied": "تم النسخ!",
|
||||
"copySuccess": "تم نسخ تقرير الخطأ إلى الحافظة",
|
||||
"copyFailed": "فشل نسخ تقرير الخطأ",
|
||||
"showDetails": "إظهار التفاصيل",
|
||||
"hideDetails": "إخفاء التفاصيل",
|
||||
"stackTrace": "تتبع المكدس",
|
||||
"componentStack": "مكدس المكوّن",
|
||||
"noStackTrace": "لا يوجد تتبع مكدس متاح",
|
||||
"fullReport": "تقرير الخطأ الكامل",
|
||||
"helpText": "إذا استمر هذا الخطأ، يرجى نسخ تقرير الخطأ أعلاه ومشاركته مع فريق الدعم. يمكنك العثور على معلومات الاتصال في صفحة \"حول\"."
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "انقر لنسخ التفاصيل",
|
||||
"clipboardEmpty": "الحافظة فارغة",
|
||||
@@ -203,6 +227,7 @@
|
||||
"emptyUrl": "يرجى إدخال رابط",
|
||||
"errorDetails": "تفاصيل الخطأ",
|
||||
"fetchInfoFailed": "فشل في جلب معلومات الفيديو",
|
||||
"invalidUrl": "محتوى الحافظة ليس رابطًا صالحًا",
|
||||
"networkError": "حدث خطأ ما. تحقق من شبكتك واستخدم رابطاً صحيحاً",
|
||||
"pasteFromClipboard": "فشل في اللصق من الحافظة"
|
||||
},
|
||||
@@ -210,10 +235,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 +267,14 @@
|
||||
"openFileLocation": "فتح موقع الملف",
|
||||
"openFolder": "فتح المجلد",
|
||||
"openInBrowser": "انقر لفتح في المتصفح",
|
||||
"removeAction": "إزالة",
|
||||
"removeItem": "إزالة العنصر",
|
||||
"select": "تحديد",
|
||||
"selectAll": "تحديد الكل",
|
||||
"selectVisible": "تحديد المرئي",
|
||||
"selectItem": "تحديد العنصر",
|
||||
"selectedCount": "تم تحديد {{count}}",
|
||||
"selectionSummary": "تم تحديد {{selected}} من {{total}} المرئية",
|
||||
"stats": {
|
||||
"cancelled": "ملغي",
|
||||
"completed": "مكتمل",
|
||||
@@ -258,9 +303,15 @@
|
||||
"downloadCompleted": "اكتمل التحميل",
|
||||
"downloadFailed": "فشل التحميل",
|
||||
"downloadStarted": "بدأ التحميل",
|
||||
"historyCleared": "تم مسح السجل",
|
||||
"historyClearFailed": "فشل مسح السجل",
|
||||
"itemRemoved": "تم إزالة العنصر",
|
||||
"itemsRemoved": "تمت إزالة {{count}} عنصرًا",
|
||||
"itemsRemoveFailed": "فشل إزالة العناصر المحددة",
|
||||
"openFileFailed": "فشل في فتح الملف",
|
||||
"openFolderFailed": "فشل في فتح المجلد",
|
||||
"playlistHistoryRemoved": "تمت إزالة قائمة التشغيل وحذف الملفات",
|
||||
"playlistHistoryRemoveFailed": "فشل إزالة سجل قائمة التشغيل",
|
||||
"removeFailed": "فشل في إزالة العنصر",
|
||||
"settingsSaved": "تم حفظ الإعدادات",
|
||||
"urlCopied": "تم نسخ الرابط إلى الحافظة",
|
||||
@@ -269,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "قائمة التشغيل",
|
||||
"clearPreview": "مسح المعاينة",
|
||||
"collapsedProgress": "جارٍ تنزيل قائمة التشغيل: {{completed}} / {{total}} مكتملة",
|
||||
"comingSoon": "ميزة تحميل قائمة التشغيل قريباً!",
|
||||
"completed": "تم تحميل قائمة التشغيل",
|
||||
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
|
||||
@@ -284,7 +336,9 @@
|
||||
"folderFormat": "تنسيق اسم المجلد لقوائم التشغيل",
|
||||
"foundVideos": "تم العثور على {{count}} فيديو في قائمة التشغيل",
|
||||
"groupActive": "{{count}} نشط",
|
||||
"groupCollapse": "طي",
|
||||
"groupErrors": "{{count}} فشل",
|
||||
"groupExpand": "توسيع",
|
||||
"groupSummary": "{{completed}} / {{total}} مكتمل",
|
||||
"linkLabel": "رابط قائمة التشغيل",
|
||||
"noEntries": "لم يتم العثور على فيديوهات في قائمة التشغيل هذه",
|
||||
@@ -299,7 +353,11 @@
|
||||
"range": "النطاق (اختياري)",
|
||||
"resetToDefault": "إعادة تعيين إلى الافتراضي",
|
||||
"selectedRange": "النطاق: {{start}}-{{end}}",
|
||||
"selectedVideos": "تم تحديد {{count}}",
|
||||
"downloadCurrentRange": "تنزيل المحدد",
|
||||
"showingCount": "عرض {{count}} فيديو",
|
||||
"selectEntry": "تحديد الإدخال {{index}}",
|
||||
"noEntriesSelected": "لا توجد إدخالات محددة",
|
||||
"startIndex": "البداية (1)",
|
||||
"title": "تحميل قائمة التشغيل",
|
||||
"totalVideos": "إجمالي الفيديوهات: {{count}}",
|
||||
@@ -312,6 +370,14 @@
|
||||
"audio": "تفضيلات الصوت",
|
||||
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
|
||||
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
|
||||
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
|
||||
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
|
||||
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
|
||||
"browserForCookiesProfileInvalid": "مسار الملف الشخصي غير صالح. اختر مجلد الملف الشخصي للمتصفح المحدد.",
|
||||
"browserForCookiesProfileInvalidPath": "هذا المجلد غير موجود. اختر مجلد ملف شخصي موجود.",
|
||||
"browserForCookiesProfileInvalidProfile": "لم يتم العثور على اسم الملف الشخصي في موقع المتصفح الافتراضي.",
|
||||
"browserForCookiesProfileInvalidUnsupported": "لا يوجد موقع ملف شخصي افتراضي معروف لهذا المتصفح على هذه المنصة.",
|
||||
"browserForCookiesProfileInvalidEmpty": "أدخل مسار ملف شخصي للمتصفح المحدد.",
|
||||
"cookiesFile": "ملف ملفات تعريف الارتباط",
|
||||
"cookiesFileDescription": "ملف ملفات تعريف الارتباط بتنسيق Netscape للتحميل للمصادقة",
|
||||
"clearCookiesFile": "مسح",
|
||||
@@ -323,9 +389,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 +408,7 @@
|
||||
"fileSelectError": "فشل في اختيار الملف",
|
||||
"general": "عام",
|
||||
"language": "اللغة",
|
||||
"languageDescription": "اختر لغتك المفضلة لواجهة التطبيق",
|
||||
"light": "فاتح",
|
||||
"hideDockIcon": "إخفاء أيقونة Dock",
|
||||
"hideDockIconDescription": "إزالة VidBee من Dock في macOS. استخدم شريط القائمة أو أيقونة الدرج لإعادة فتح التطبيق.",
|
||||
@@ -346,6 +417,14 @@
|
||||
"launchAtLoginUnsupported": "البدء التلقائي متاح فقط على macOS و Windows.",
|
||||
"enableAnalytics": "مساعدة في تحسين VidBee",
|
||||
"enableAnalyticsDescription": "مشاركة بيانات الاستخدام المجهولة لمساعدتنا في فهم كيفية استخدام التطبيق وأولويات التحسينات.",
|
||||
"embedChapters": "تضمين الفصول",
|
||||
"embedChaptersDescription": "إضافة علامات الفصول إلى الملف عند توفرها",
|
||||
"embedMetadata": "تضمين البيانات الوصفية",
|
||||
"embedMetadataDescription": "كتابة العنوان والفنان وبيانات وصفية أخرى عند توفرها",
|
||||
"embedSubs": "تضمين الترجمات",
|
||||
"embedSubsDescription": "تضمين الترجمات داخل ملف الفيديو (mp4، webm، mkv)",
|
||||
"embedThumbnail": "تضمين الصورة المصغرة",
|
||||
"embedThumbnailDescription": "إضافة الصورة المصغرة كغلاف",
|
||||
"maxConcurrentDownloads": "العدد الأقصى للتحميلات النشطة",
|
||||
"maxConcurrentDownloadsDescription": "العدد الأقصى للتحميلات المتزامنة",
|
||||
"none": "لا شيء",
|
||||
@@ -371,8 +450,7 @@
|
||||
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
|
||||
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
|
||||
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
|
||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
|
||||
},
|
||||
"system": "النظام",
|
||||
"theme": "المظهر",
|
||||
@@ -393,7 +471,6 @@
|
||||
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
|
||||
"downloadDirectory": "مجلد التحميل",
|
||||
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
|
||||
"checkInterval": "فترة الفحص (ساعات)",
|
||||
"onlyLatest": "تحميل أحدث فيديو فقط",
|
||||
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
|
||||
},
|
||||
|
||||
@@ -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,30 @@
|
||||
"formatNote": "Format-Hinweis",
|
||||
"protocol": "Protokoll",
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"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 +227,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 +235,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 +267,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 +303,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 +320,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 +336,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 +353,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 +370,14 @@
|
||||
"audio": "Audio-Einstellungen",
|
||||
"browserForCookies": "Browser für Cookies auswählen",
|
||||
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
|
||||
"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 +389,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 +408,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 +417,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 +450,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 +471,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."
|
||||
},
|
||||
|
||||
@@ -50,10 +50,12 @@
|
||||
"documentation": "Help center",
|
||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||
"feedback": "Feedback & issues",
|
||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
||||
"githubIssues": "GitHub Issues",
|
||||
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
|
||||
"githubIssues": "GitHub",
|
||||
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||
"discord": "Discord Community",
|
||||
"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.",
|
||||
@@ -87,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",
|
||||
@@ -102,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",
|
||||
@@ -138,6 +130,9 @@
|
||||
"error": "Error",
|
||||
"fetch": "Fetch",
|
||||
"fetchingVideoInfo": "Fetching video info...",
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
},
|
||||
"history": "History",
|
||||
"imageLoadError": "Image failed to load",
|
||||
"imagePlaceholder": "No image available",
|
||||
@@ -157,6 +152,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",
|
||||
@@ -204,6 +200,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",
|
||||
@@ -212,6 +227,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"
|
||||
},
|
||||
@@ -340,6 +356,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}}",
|
||||
@@ -351,7 +369,15 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -366,7 +392,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",
|
||||
@@ -379,6 +408,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.",
|
||||
@@ -387,6 +417,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",
|
||||
@@ -412,8 +450,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",
|
||||
@@ -434,7 +471,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."
|
||||
},
|
||||
|
||||
@@ -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,30 @@
|
||||
"formatNote": "Nota de formato",
|
||||
"protocol": "Protocolo",
|
||||
"subscription": "Suscripción"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"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 +227,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 +235,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 +267,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 +303,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 +320,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 +336,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 +353,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 +370,14 @@
|
||||
"audio": "Preferencias de Audio",
|
||||
"browserForCookies": "Seleccionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
|
||||
"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 +389,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 +408,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 +417,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 +450,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 +471,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."
|
||||
},
|
||||
|
||||
@@ -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,55 @@
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"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 +227,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 +235,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 +267,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 +292,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 +303,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 +320,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 +336,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 +348,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 +370,61 @@
|
||||
"audio": "Préférences Audio",
|
||||
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
||||
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
||||
"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 +442,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 +450,7 @@
|
||||
"showMoreFormats": "Afficher plus d'options de format",
|
||||
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Modèle utilisé lorsqu’un 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é lorsqu’un abonnement ne remplace pas son nom de fichier."
|
||||
},
|
||||
"system": "Système",
|
||||
"theme": "Thème",
|
||||
@@ -384,6 +462,119 @@
|
||||
},
|
||||
"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 d’attente 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."
|
||||
},
|
||||
"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 +659,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 d’attente 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,30 @@
|
||||
"formatNote": "Catatan format",
|
||||
"protocol": "Protokol",
|
||||
"subscription": "Berlangganan"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"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 +227,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 +235,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 +267,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 +303,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 +320,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 +336,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 +353,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 +370,14 @@
|
||||
"audio": "Preferensi Audio",
|
||||
"browserForCookies": "Pilih browser untuk menggunakan cookie",
|
||||
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
|
||||
"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 +389,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 +408,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 +417,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 +450,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 +471,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."
|
||||
},
|
||||
|
||||
@@ -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,55 @@
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"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 +227,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 +235,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 +267,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 +292,9 @@
|
||||
"about": "Informazioni",
|
||||
"download": "Scarica",
|
||||
"playlist": "Scarica Playlist",
|
||||
"preferences": "Preferenze",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Abbonamenti",
|
||||
"preferences": "Preferenze",
|
||||
"supportedSites": "Siti Supportati",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
@@ -258,9 +303,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 +320,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 +336,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 +348,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 +370,61 @@
|
||||
"audio": "Preferenze Audio",
|
||||
"browserForCookies": "Seleziona browser per usare i cookie",
|
||||
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
||||
"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 +442,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 +450,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 +462,119 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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 +659,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,55 @@
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "問題が発生しました",
|
||||
"description": "予期しないエラーが発生しました。アプリを再読み込みするか、問題が続く場合は報告してください。",
|
||||
"message": "エラーメッセージ",
|
||||
"unknownError": "不明なエラーが発生しました",
|
||||
"goHome": "ホームへ",
|
||||
"reload": "アプリを再読み込み",
|
||||
"copyReport": "エラーレポートをコピー",
|
||||
"copied": "コピーしました!",
|
||||
"copySuccess": "エラーレポートをクリップボードにコピーしました",
|
||||
"copyFailed": "エラーレポートのコピーに失敗しました",
|
||||
"showDetails": "詳細を表示",
|
||||
"hideDetails": "詳細を非表示",
|
||||
"stackTrace": "スタックトレース",
|
||||
"componentStack": "コンポーネントスタック",
|
||||
"noStackTrace": "利用可能なスタックトレースはありません",
|
||||
"fullReport": "完全なエラーレポート",
|
||||
"helpText": "このエラーが続く場合は、上記のエラーレポートをコピーしてサポートチームに共有してください。連絡先は「概要」ページにあります。"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "詳細をコピーするにはクリック",
|
||||
@@ -203,6 +227,7 @@
|
||||
"emptyUrl": "URLを入力してください",
|
||||
"errorDetails": "エラーの詳細",
|
||||
"fetchInfoFailed": "ビデオ情報の取得に失敗",
|
||||
"invalidUrl": "クリップボードの内容が有効なURLではありません",
|
||||
"networkError": "エラーが発生しました。ネットワークを確認し、正しいURLを使用してください",
|
||||
"pasteFromClipboard": "クリップボードからの貼り付けに失敗"
|
||||
},
|
||||
@@ -210,10 +235,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 +267,14 @@
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"openFolder": "フォルダを開く",
|
||||
"openInBrowser": "ブラウザで開くにはクリック",
|
||||
"removeAction": "削除",
|
||||
"removeItem": "アイテムを削除",
|
||||
"select": "選択",
|
||||
"selectAll": "すべて選択",
|
||||
"selectVisible": "表示中を選択",
|
||||
"selectItem": "項目を選択",
|
||||
"selectedCount": "{{count}} 件選択",
|
||||
"selectionSummary": "{{total}} 件中 {{selected}} 件を選択",
|
||||
"stats": {
|
||||
"cancelled": "キャンセル済み",
|
||||
"completed": "完了",
|
||||
@@ -247,9 +292,9 @@
|
||||
"about": "について",
|
||||
"download": "ダウンロード",
|
||||
"playlist": "プレイリストをダウンロード",
|
||||
"preferences": "設定",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "定期購入",
|
||||
"preferences": "設定",
|
||||
"supportedSites": "サポートされているサイト",
|
||||
"theme": "テーマ:"
|
||||
},
|
||||
@@ -258,9 +303,15 @@
|
||||
"downloadCompleted": "ダウンロード完了",
|
||||
"downloadFailed": "ダウンロードに失敗",
|
||||
"downloadStarted": "ダウンロード開始",
|
||||
"historyCleared": "履歴をクリアしました",
|
||||
"historyClearFailed": "履歴のクリアに失敗しました",
|
||||
"itemRemoved": "アイテムが削除されました",
|
||||
"itemsRemoved": "{{count}} 件を削除しました",
|
||||
"itemsRemoveFailed": "選択した項目の削除に失敗しました",
|
||||
"openFileFailed": "ファイルの開封に失敗",
|
||||
"openFolderFailed": "フォルダの開封に失敗",
|
||||
"playlistHistoryRemoved": "プレイリストを削除し、ファイルを削除しました",
|
||||
"playlistHistoryRemoveFailed": "プレイリスト履歴の削除に失敗しました",
|
||||
"removeFailed": "アイテムの削除に失敗",
|
||||
"settingsSaved": "設定が保存されました",
|
||||
"urlCopied": "URLがクリップボードにコピーされました",
|
||||
@@ -269,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "プレイリスト",
|
||||
"clearPreview": "プレビューをクリアする",
|
||||
"collapsedProgress": "プレイリストをダウンロード中: {{completed}} / {{total}} 完了",
|
||||
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
|
||||
"completed": "プレイリストがダウンロードされました",
|
||||
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
||||
@@ -284,8 +336,10 @@
|
||||
"folderFormat": "プレイリスト用フォルダ名フォーマット",
|
||||
"foundVideos": "プレイリストで{{count}}個のビデオを発見",
|
||||
"groupActive": "{{count}} 個がアクティブです",
|
||||
"groupCollapse": "折りたたむ",
|
||||
"groupErrors": "{{count}} 回失敗しました",
|
||||
"groupSummary": "{{完了}} / {{合計}} 完了",
|
||||
"groupExpand": "展開",
|
||||
"groupSummary": "{{completed}} / {{total}} 完了",
|
||||
"linkLabel": "プレイリストURL",
|
||||
"noEntries": "このプレイリストにはビデオが見つかりませんでした",
|
||||
"noEntriesInRange": "選択した範囲にビデオがありません",
|
||||
@@ -294,12 +348,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 +370,61 @@
|
||||
"audio": "オーディオ設定",
|
||||
"browserForCookies": "Cookieに使用するブラウザを選択",
|
||||
"browserForCookiesDescription": "認証用の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 +442,6 @@
|
||||
"normal": "通常",
|
||||
"worst": "最悪"
|
||||
},
|
||||
"openLinkError": "リンクを開けませんでした",
|
||||
"proxy": "プロキシ",
|
||||
"proxyDescription": "ネットワークリクエスト用のプロキシサーバー",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -371,8 +450,7 @@
|
||||
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
||||
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
|
||||
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 ~ 24 時間)。"
|
||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
|
||||
},
|
||||
"system": "システム",
|
||||
"theme": "テーマ",
|
||||
@@ -384,6 +462,119 @@
|
||||
},
|
||||
"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 を解決できませんでした。"
|
||||
},
|
||||
"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 +659,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": "定期購入"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,55 @@
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "문제가 발생했습니다",
|
||||
"description": "예기치 않은 오류가 발생했습니다. 앱을 다시 로드하거나 문제가 계속되면 보고해 주세요.",
|
||||
"message": "오류 메시지",
|
||||
"unknownError": "알 수 없는 오류가 발생했습니다",
|
||||
"goHome": "홈으로",
|
||||
"reload": "앱 다시 로드",
|
||||
"copyReport": "오류 보고서 복사",
|
||||
"copied": "복사됨!",
|
||||
"copySuccess": "오류 보고서가 클립보드에 복사되었습니다",
|
||||
"copyFailed": "오류 보고서 복사에 실패했습니다",
|
||||
"showDetails": "세부 정보 표시",
|
||||
"hideDetails": "세부 정보 숨기기",
|
||||
"stackTrace": "스택 트레이스",
|
||||
"componentStack": "컴포넌트 스택",
|
||||
"noStackTrace": "사용 가능한 스택 트레이스가 없습니다",
|
||||
"fullReport": "전체 오류 보고서",
|
||||
"helpText": "이 오류가 계속되면 위의 오류 보고서를 복사하여 지원 팀에 공유하세요. 연락처 정보는 정보 페이지에서 찾을 수 있습니다."
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "세부 정보 복사하려면 클릭",
|
||||
@@ -203,6 +227,7 @@
|
||||
"emptyUrl": "URL을 입력하세요",
|
||||
"errorDetails": "오류 세부 정보",
|
||||
"fetchInfoFailed": "비디오 정보 가져오기 실패",
|
||||
"invalidUrl": "클립보드 내용이 올바른 URL이 아닙니다",
|
||||
"networkError": "오류가 발생했습니다. 네트워크를 확인하고 올바른 URL을 사용하세요",
|
||||
"pasteFromClipboard": "클립보드에서 붙여넣기 실패"
|
||||
},
|
||||
@@ -210,10 +235,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 +267,14 @@
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"openFolder": "폴더 열기",
|
||||
"openInBrowser": "브라우저에서 열려면 클릭",
|
||||
"removeAction": "제거",
|
||||
"removeItem": "항목 제거",
|
||||
"select": "선택",
|
||||
"selectAll": "모두 선택",
|
||||
"selectVisible": "표시된 항목 선택",
|
||||
"selectItem": "항목 선택",
|
||||
"selectedCount": "{{count}}개 선택됨",
|
||||
"selectionSummary": "표시된 {{total}}개 중 {{selected}}개 선택됨",
|
||||
"stats": {
|
||||
"cancelled": "취소됨",
|
||||
"completed": "완료",
|
||||
@@ -247,9 +292,9 @@
|
||||
"about": "정보",
|
||||
"download": "다운로드",
|
||||
"playlist": "재생목록 다운로드",
|
||||
"preferences": "환경설정",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "구독",
|
||||
"preferences": "환경설정",
|
||||
"supportedSites": "지원되는 사이트",
|
||||
"theme": "테마:"
|
||||
},
|
||||
@@ -258,9 +303,15 @@
|
||||
"downloadCompleted": "다운로드 완료",
|
||||
"downloadFailed": "다운로드 실패",
|
||||
"downloadStarted": "다운로드 시작됨",
|
||||
"historyCleared": "기록이 지워졌습니다",
|
||||
"historyClearFailed": "기록을 지우지 못했습니다",
|
||||
"itemRemoved": "항목 제거됨",
|
||||
"itemsRemoved": "{{count}}개 항목이 제거되었습니다",
|
||||
"itemsRemoveFailed": "선택한 항목을 제거하지 못했습니다",
|
||||
"openFileFailed": "파일 열기 실패",
|
||||
"openFolderFailed": "폴더 열기 실패",
|
||||
"playlistHistoryRemoved": "재생목록이 제거되고 파일이 삭제되었습니다",
|
||||
"playlistHistoryRemoveFailed": "재생목록 기록을 제거하지 못했습니다",
|
||||
"removeFailed": "항목 제거 실패",
|
||||
"settingsSaved": "설정 저장됨",
|
||||
"urlCopied": "URL이 클립보드에 복사됨",
|
||||
@@ -269,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "재생목록",
|
||||
"clearPreview": "미리보기 지우기",
|
||||
"collapsedProgress": "재생목록 다운로드 중: {{completed}} / {{total}} 완료",
|
||||
"comingSoon": "재생목록 다운로드 기능이 곧 출시됩니다!",
|
||||
"completed": "재생목록 다운로드됨",
|
||||
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
||||
@@ -284,8 +336,10 @@
|
||||
"folderFormat": "재생목록용 폴더명 형식",
|
||||
"foundVideos": "재생목록에서 {{count}}개 비디오 발견",
|
||||
"groupActive": "{{count}} 활성",
|
||||
"groupCollapse": "접기",
|
||||
"groupErrors": "{{count}}개 실패",
|
||||
"groupSummary": "{{완료}} / {{총}} 완료",
|
||||
"groupExpand": "펼치기",
|
||||
"groupSummary": "{{completed}} / {{total}} 완료",
|
||||
"linkLabel": "재생목록 URL",
|
||||
"noEntries": "이 재생목록에는 동영상이 없습니다.",
|
||||
"noEntriesInRange": "선택한 범위에 동영상이 없습니다.",
|
||||
@@ -294,12 +348,16 @@
|
||||
"positionLabel": "{{total}}개 항목 중 {{index}}개 항목",
|
||||
"previewButton": "미리보기 재생목록",
|
||||
"previewFailed": "재생목록을 미리 볼 수 없습니다.",
|
||||
"previewRequired": "다운로드하기 전에 재생 목록을 미리 봅니다.",
|
||||
"previewSummary": "다운로드하기 전에 재생 목록 항목을 미리 봅니다.",
|
||||
"previewRequired": "다운로드하기 전에 재생 목록을 미리 봅니다.",
|
||||
"range": "범위 (선택사항)",
|
||||
"resetToDefault": "기본값으로 재설정",
|
||||
"selectedRange": "범위: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}}개 선택됨",
|
||||
"downloadCurrentRange": "선택 항목 다운로드",
|
||||
"showingCount": "{{count}}개의 동영상 표시 중",
|
||||
"selectEntry": "항목 {{index}} 선택",
|
||||
"noEntriesSelected": "선택된 항목이 없습니다",
|
||||
"startIndex": "시작 (1)",
|
||||
"title": "재생목록 다운로드",
|
||||
"totalVideos": "총 동영상 수: {{count}}",
|
||||
@@ -312,39 +370,61 @@
|
||||
"audio": "오디오 환경설정",
|
||||
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
||||
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
||||
"browserForCookiesProfile": "프로필 이름 또는 경로",
|
||||
"browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.",
|
||||
"browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)",
|
||||
"browserForCookiesProfileInvalid": "프로필 경로가 유효하지 않습니다. 선택한 브라우저의 프로필 폴더를 선택하세요.",
|
||||
"browserForCookiesProfileInvalidPath": "해당 폴더가 없습니다. 기존 프로필 폴더를 선택하세요.",
|
||||
"browserForCookiesProfileInvalidProfile": "기본 브라우저 위치에서 프로필 이름을 찾을 수 없습니다.",
|
||||
"browserForCookiesProfileInvalidUnsupported": "이 플랫폼에서 이 브라우저의 기본 프로필 위치가 알려져 있지 않습니다.",
|
||||
"browserForCookiesProfileInvalidEmpty": "선택한 브라우저의 프로필 경로를 입력하세요.",
|
||||
"cookiesFile": "쿠키 파일",
|
||||
"cookiesFileDescription": "인증을 위해 로드할 Netscape 형식의 쿠키 파일",
|
||||
"clearCookiesFile": "분명한",
|
||||
"cookiesHelpTitle": "쿠키 사용",
|
||||
"cookiesHelpBrowser": "로그인된 세션을 자동으로 재사용하려면 위에서 브라우저를 선택하세요.",
|
||||
"cookiesHelpFile": "Netscape 쿠키 파일을 내보내고(yt-dlp FAQ 참조) 필요할 때 여기에서 선택하세요.",
|
||||
"cookiesHelpFaq": "yt-dlp 쿠키 FAQ 열기",
|
||||
"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 형식의 쿠키 파일",
|
||||
"cookiesHelpBrowser": "로그인된 세션을 자동으로 재사용하려면 위에서 브라우저를 선택하세요.",
|
||||
"cookiesHelpFaq": "yt-dlp 쿠키 FAQ 열기",
|
||||
"cookiesHelpFile": "Netscape 쿠키 파일을 내보내고(yt-dlp FAQ 참조) 필요할 때 여기에서 선택하세요.",
|
||||
"cookiesHelpTitle": "쿠키 사용",
|
||||
"clearConfigFile": "분명한",
|
||||
"dark": "다크",
|
||||
"description": "다운로드 환경설정 및 앱 설정 구성",
|
||||
"directorySelectError": "디렉토리 선택 실패",
|
||||
"downloadPath": "다운로드 위치",
|
||||
"downloadPathDescription": "다운로드 파일을 저장할 위치 선택",
|
||||
"enableAnalytics": "VidBee 개선에 참여해주세요",
|
||||
"enableAnalyticsDescription": "익명의 사용 데이터를 공유하면 앱이 어떻게 사용되는지 이해하고 개선 우선순위를 정하는 데 도움이 됩니다.",
|
||||
"fileSelectError": "파일 선택 실패",
|
||||
"general": "일반",
|
||||
"language": "언어",
|
||||
"languageDescription": "앱 인터페이스에 사용할 언어를 선택하세요",
|
||||
"light": "라이트",
|
||||
"hideDockIcon": "Dock 아이콘 숨기기",
|
||||
"hideDockIconDescription": "macOS Dock에서 VidBee를 제거합니다. \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 +442,6 @@
|
||||
"normal": "보통",
|
||||
"worst": "최악"
|
||||
},
|
||||
"openLinkError": "링크를 열지 못했습니다.",
|
||||
"proxy": "프록시",
|
||||
"proxyDescription": "네트워크 요청용 프록시 서버",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -371,8 +450,7 @@
|
||||
"showMoreFormats": "더 많은 형식 옵션 표시",
|
||||
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
|
||||
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
|
||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다."
|
||||
},
|
||||
"system": "시스템",
|
||||
"theme": "테마",
|
||||
@@ -384,6 +462,119 @@
|
||||
},
|
||||
"video": "비디오 환경설정"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "구독",
|
||||
"subtitle": "{{count}} 구독{{count, plural, 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을 확인하지 못했습니다."
|
||||
},
|
||||
"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 +659,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": "{{플랫폼}} 피드 감지됨 -> {{feed}}",
|
||||
"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": "다운로드 상태: {{status}}",
|
||||
"notQueued": "아직 다운로드 대기열에 없습니다"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"noThumbnail": "미리보기 이미지 없음",
|
||||
"subscription": "신청",
|
||||
"unknown": "알 수 없는 구독"
|
||||
},
|
||||
"lastChecked": "마지막 확인: {{time}}",
|
||||
"latestVideo": "최신 동영상: {{제목}}",
|
||||
"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, plural, one {} other {s}}}",
|
||||
"title": "구독"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"betaProgramDescription": "Receba builds antecipados e próximos recursos antes de todos.",
|
||||
"betaProgramTitle": "Canal de visualização",
|
||||
"description": "VidBee é um baixador gratuito e de código aberto construído com Electron e alimentado por yt-dlp.",
|
||||
"downloadingUpdate": "Baixando atualização",
|
||||
"followAuthorActions": {
|
||||
"follow": "Seguir @nexmoex"
|
||||
},
|
||||
@@ -25,12 +24,6 @@
|
||||
"followAuthorTitle": "Seguir o Desenvolvedor",
|
||||
"here": "aqui",
|
||||
"homepage": "Página inicial",
|
||||
"latestVersionBadge": "Mais recente: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nova versão disponível",
|
||||
"error": "Não foi possível obter a versão mais recente",
|
||||
"uptodate": "Você está atualizado"
|
||||
},
|
||||
"notifications": {
|
||||
"checkingUpdates": "Procurando atualizações...",
|
||||
"downloadError": "Falha ao baixar atualização",
|
||||
@@ -38,14 +31,14 @@
|
||||
"downloadUpdate": "Baixar e instalar atualização {{version}}?",
|
||||
"manualDownloadAction": "Baixe agora",
|
||||
"noUpdatesAvailable": "Você está usando a versão mais recente",
|
||||
"restartNowAction": "Reinicie agora",
|
||||
"restartToUpdate": "Reiniciar agora para instalar atualização?",
|
||||
"unknownErrorFallback": "Erro desconhecido",
|
||||
"restartNowAction": "Reinicie agora",
|
||||
"updateAvailable": "Atualização disponível: {{version}}",
|
||||
"updateAvailableMessage": "Uma nova versão {{version}} está disponível. \nFaça o download no site oficial.",
|
||||
"updateDownloaded": "Atualização baixada, reinicie para instalar",
|
||||
"updateDownloadedVersion": "Atualização {{version}} baixada, reinicie para instalar",
|
||||
"updateError": "Falha ao verificar atualizações: {{error}}"
|
||||
"updateError": "Falha ao verificar atualizações: {{error}}",
|
||||
"unknownErrorFallback": "Erro desconhecido"
|
||||
},
|
||||
"preferencesDescription": "Ajuste configurações de atualização sem sair desta página.",
|
||||
"preferencesTitle": "Alternâncias Rápidas",
|
||||
@@ -58,6 +51,12 @@
|
||||
"documentationDescription": "Guias, FAQs e fluxos de trabalho comuns.",
|
||||
"feedback": "Feedback e problemas",
|
||||
"feedbackDescription": "Compartilhe ideias ou reporte problemas no GitHub.",
|
||||
"githubIssues": "GitHub",
|
||||
"githubIssuesDescription": "Relatar bugs ou solicitar recursos no GitHub.",
|
||||
"xFeedback": "Twitter",
|
||||
"xFeedbackDescription": "Compartilhe feedback ou sugestões no X mencionando @nexmoex.",
|
||||
"discord": "Discord",
|
||||
"discordDescription": "Junte-se à nossa comunidade no Discord para discussões e suporte.",
|
||||
"license": "Licença",
|
||||
"licenseDescription": "Revise os termos da licença de código aberto.",
|
||||
"website": "Site oficial",
|
||||
@@ -76,13 +75,21 @@
|
||||
"sourceCode": "Código fonte disponível",
|
||||
"title": "Sobre",
|
||||
"version": "Versão",
|
||||
"versionLabel": "v{{version}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Mais recente: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nova versão disponível",
|
||||
"uptodate": "Você está atualizado",
|
||||
"error": "Não foi possível obter a versão mais recente"
|
||||
},
|
||||
"downloadingUpdate": "Baixando atualização"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fechar aplicativo quando download terminar",
|
||||
"currentLocation": "Local de download atual - ",
|
||||
"downloadLocation": "Local de download",
|
||||
"downloadSubs": "Baixar legendas se disponíveis",
|
||||
"downloadSubsHint": "Salvar legendas como arquivos separados quando disponíveis",
|
||||
"end": "Fim",
|
||||
"endHint": "Se deixado vazio, será baixado até o final",
|
||||
"endPlaceholder": "10:00",
|
||||
@@ -98,17 +105,6 @@
|
||||
"description": "Baixar vídeos e áudios de centenas de sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Ruim",
|
||||
"best": "Melhor",
|
||||
"extract": "Extrair",
|
||||
"good": "Bom",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectQuality": "Selecionar Qualidade",
|
||||
"title": "Extrair Áudio",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"download": {
|
||||
"active": "Ativo",
|
||||
"all": "Todos",
|
||||
@@ -123,6 +119,10 @@
|
||||
"downloadBtn": "Baixar",
|
||||
"downloadPending": "Pendente",
|
||||
"downloadQueue": "Fila de Download",
|
||||
"customDownloadFolder": "Pasta de download personalizada",
|
||||
"autoFolderPlaceholder": "Pasta automática (com base nos metadados)",
|
||||
"autoFolderHint": "Pastas automáticas são criadas a partir de metadados.",
|
||||
"useAutoFolder": "Usar pasta automática",
|
||||
"downloadVideo": "Baixar Vídeo",
|
||||
"downloading": "Baixando...",
|
||||
"enterUrl": "Inserir URL do Vídeo",
|
||||
@@ -130,44 +130,17 @@
|
||||
"error": "Erro",
|
||||
"fetch": "Buscar",
|
||||
"fetchingVideoInfo": "Buscando informações do vídeo...",
|
||||
"goToSettings": "Vá para Configurações",
|
||||
"hideDetails": "Ocultar detalhes",
|
||||
"history": "Histórico",
|
||||
"imageLoadError": "Falha ao carregar imagem",
|
||||
"imagePlaceholder": "Nenhuma imagem disponível",
|
||||
"infoUnavailable": "Download de Um Clique (Info indisponível)",
|
||||
"loading": "Carregando",
|
||||
"metadata": {
|
||||
"audioCodec": "Codec de áudio",
|
||||
"codec": "Codec",
|
||||
"completedAt": "Concluído em",
|
||||
"createdAt": "Criado em",
|
||||
"description": "Descrição",
|
||||
"downloadPath": "Caminho de download",
|
||||
"fileSize": "Tamanho do arquivo",
|
||||
"format": "Formatar",
|
||||
"formatNote": "Formatar nota",
|
||||
"fps": "FPS",
|
||||
"height": "Altura",
|
||||
"playlist": "Lista de reprodução",
|
||||
"protocol": "Protocolo",
|
||||
"quality": "Qualidade",
|
||||
"savedFile": "Arquivo salvo",
|
||||
"source": "Fonte",
|
||||
"speed": "Velocidade",
|
||||
"startedAt": "Começou em",
|
||||
"subscription": "Subscrição",
|
||||
"tags": "Etiquetas",
|
||||
"url": "URL de origem",
|
||||
"videoCodec": "Codec de vídeo",
|
||||
"views": "Visualizações",
|
||||
"width": "Largura"
|
||||
},
|
||||
"moreOptions": "Mais opções",
|
||||
"noActiveDownloads": "Nenhum download ativo",
|
||||
"noAudio": "Sem Áudio",
|
||||
"noHistory": "Nenhum histórico de download",
|
||||
"noItems": "Nenhum item encontrado",
|
||||
"goToSettings": "Vá para Configurações",
|
||||
"oneClickDownload": "Download de Um Clique",
|
||||
"oneClickDownloadDescription": "Baixar diretamente com configurações padrão sem confirmação",
|
||||
"oneClickDownloadEnabled": "O download com um clique está ativado. \nOs downloads começarão diretamente com as configurações padrão.",
|
||||
@@ -176,14 +149,17 @@
|
||||
"paste": "Colar",
|
||||
"pastePlaylistUrl": "Clique para colar link da playlist da área de transferência [Ctrl + V]",
|
||||
"pasteUrl": "Clique para colar URL do vídeo ou ID [Ctrl + V]",
|
||||
"pasteUrlButton": "Colar URL",
|
||||
"preparing": "Preparando...",
|
||||
"processing": "Processando",
|
||||
"progress": "Progresso",
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"startDownload": "Iniciar download",
|
||||
"showDetails": "Mostrar detalhes",
|
||||
"hideDetails": "Ocultar detalhes",
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectDownloadType": "Selecionar tipo de download",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"startDownload": "Iniciar download",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"singleVideo": "Vídeo Único",
|
||||
"speed": "Velocidade",
|
||||
"title": "Título",
|
||||
@@ -193,7 +169,55 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Vídeo",
|
||||
"videoInfo": "Informações do Vídeo",
|
||||
"videoInfoUpdated": "Informações do vídeo atualizadas"
|
||||
"videoInfoUpdated": "Informações do vídeo atualizadas",
|
||||
"metadata": {
|
||||
"source": "Fonte",
|
||||
"playlist": "Lista de reprodução",
|
||||
"format": "Formatar",
|
||||
"quality": "Qualidade",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Arquivo salvo",
|
||||
"url": "URL de origem",
|
||||
"description": "Descrição",
|
||||
"views": "Visualizações",
|
||||
"tags": "Etiquetas",
|
||||
"downloadPath": "Caminho de download",
|
||||
"createdAt": "Criado em",
|
||||
"startedAt": "Começou em",
|
||||
"completedAt": "Concluído em",
|
||||
"speed": "Velocidade",
|
||||
"fileSize": "Tamanho do arquivo",
|
||||
"width": "Largura",
|
||||
"height": "Altura",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Codec de vídeo",
|
||||
"audioCodec": "Codec de áudio",
|
||||
"formatNote": "Formatar nota",
|
||||
"protocol": "Protocolo",
|
||||
"subscription": "Subscrição"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Algo deu errado",
|
||||
"description": "Ocorreu um erro inesperado. Recarregue o aplicativo ou reporte este problema se persistir.",
|
||||
"message": "Mensagem de erro",
|
||||
"unknownError": "Ocorreu um erro desconhecido",
|
||||
"goHome": "Ir para a página inicial",
|
||||
"reload": "Recarregar aplicativo",
|
||||
"copyReport": "Copiar relatório de erro",
|
||||
"copied": "Copiado!",
|
||||
"copySuccess": "Relatório de erro copiado para a área de transferência",
|
||||
"copyFailed": "Falha ao copiar o relatório de erro",
|
||||
"showDetails": "Mostrar detalhes",
|
||||
"hideDetails": "Ocultar detalhes",
|
||||
"stackTrace": "Rastro de pilha",
|
||||
"componentStack": "Pilha de componentes",
|
||||
"noStackTrace": "Nenhum rastro de pilha disponível",
|
||||
"fullReport": "Relatório de erro completo",
|
||||
"helpText": "Se esse erro persistir, copie o relatório de erro acima e compartilhe com a equipe de suporte. Você pode encontrar as informações de contato na página Sobre."
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Clique para copiar detalhes",
|
||||
@@ -203,6 +227,7 @@
|
||||
"emptyUrl": "Por favor, insira uma URL",
|
||||
"errorDetails": "Detalhes do Erro",
|
||||
"fetchInfoFailed": "Falha ao buscar informações do vídeo",
|
||||
"invalidUrl": "O conteúdo da área de transferência não é uma URL válida",
|
||||
"networkError": "Algum erro ocorreu. Verifique sua rede e use uma URL correta",
|
||||
"pasteFromClipboard": "Falha ao colar da área de transferência"
|
||||
},
|
||||
@@ -210,10 +235,23 @@
|
||||
"clearCancelled": "Limpar Cancelados",
|
||||
"clearCompleted": "Limpar Concluídos",
|
||||
"clearErrors": "Limpar Erros",
|
||||
"clearAll": "Limpar todo o histórico",
|
||||
"clearAllAction": "Limpar histórico",
|
||||
"clearSelection": "Limpar seleção",
|
||||
"confirmClearAllTitle": "Limpar todo o histórico?",
|
||||
"confirmClearAllDescription": "Remover {{count}} itens do seu histórico. Os arquivos permanecem no disco.",
|
||||
"confirmDeleteSelectedTitle": "Remover itens selecionados?",
|
||||
"confirmDeleteSelectedDescription": "Remover {{count}} itens do seu histórico. Os arquivos permanecem no disco.",
|
||||
"alsoDeleteFiles": "Também excluir arquivos",
|
||||
"confirmDeletePlaylistTitle": "Remover histórico da playlist?",
|
||||
"confirmDeletePlaylistDescription": "Remover {{count}} itens de {{title}} e excluir seus arquivos.",
|
||||
"copyToClipboard": "Copiar para área de transferência",
|
||||
"copyUrl": "Copiar URL",
|
||||
"date": "Data",
|
||||
"deletePlaylist": "Remover playlist",
|
||||
"deleteSelected": "Remover selecionados",
|
||||
"description": "Ver e gerenciar seu histórico de downloads",
|
||||
"doneSelecting": "Concluído",
|
||||
"duration": "Duração",
|
||||
"fileSize": "Tamanho do Arquivo",
|
||||
"filters": {
|
||||
@@ -229,7 +267,14 @@
|
||||
"openFileLocation": "Abrir Localização do Arquivo",
|
||||
"openFolder": "Abrir Pasta",
|
||||
"openInBrowser": "Clique para abrir no navegador",
|
||||
"removeAction": "Remover",
|
||||
"removeItem": "Remover Item",
|
||||
"select": "Selecionar",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"selectVisible": "Selecionar visíveis",
|
||||
"selectItem": "Selecionar item",
|
||||
"selectedCount": "{{count}} selecionados",
|
||||
"selectionSummary": "{{selected}} de {{total}} visíveis selecionados",
|
||||
"stats": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
@@ -247,9 +292,9 @@
|
||||
"about": "Sobre",
|
||||
"download": "Download",
|
||||
"playlist": "Baixar Playlist",
|
||||
"preferences": "Preferências",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Assinaturas",
|
||||
"preferences": "Preferências",
|
||||
"supportedSites": "Sites Suportados",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
@@ -258,9 +303,15 @@
|
||||
"downloadCompleted": "Download concluído",
|
||||
"downloadFailed": "Download falhou",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"historyCleared": "Histórico limpo",
|
||||
"historyClearFailed": "Falha ao limpar histórico",
|
||||
"itemRemoved": "Item removido",
|
||||
"itemsRemoved": "{{count}} itens removidos",
|
||||
"itemsRemoveFailed": "Falha ao remover itens selecionados",
|
||||
"openFileFailed": "Falha ao abrir arquivo",
|
||||
"openFolderFailed": "Falha ao abrir pasta",
|
||||
"playlistHistoryRemoved": "Playlist removida e arquivos excluídos",
|
||||
"playlistHistoryRemoveFailed": "Falha ao remover histórico da playlist",
|
||||
"removeFailed": "Falha ao remover item",
|
||||
"settingsSaved": "Configurações salvas",
|
||||
"urlCopied": "URL copiada para área de transferência",
|
||||
@@ -269,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "Lista de reprodução",
|
||||
"clearPreview": "Limpar visualização",
|
||||
"collapsedProgress": "Baixando playlist: {{completed}} / {{total}} concluído",
|
||||
"comingSoon": "Recurso de download de playlist em breve!",
|
||||
"completed": "Playlist baixada",
|
||||
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
|
||||
@@ -284,22 +336,28 @@
|
||||
"folderFormat": "Formato de nome de pasta para playlists",
|
||||
"foundVideos": "Encontrados {{count}} vídeos na playlist",
|
||||
"groupActive": "{{count}} ativo",
|
||||
"groupErrors": "{{contagem}} falhou",
|
||||
"groupSummary": "{{concluído}} / {{total}} concluído",
|
||||
"groupCollapse": "Recolher",
|
||||
"groupErrors": "{{count}} falhou",
|
||||
"groupExpand": "Expandir",
|
||||
"groupSummary": "{{completed}} / {{total}} concluído",
|
||||
"linkLabel": "URL da Playlist",
|
||||
"noEntries": "Nenhum vídeo foi encontrado nesta playlist",
|
||||
"noEntriesInRange": "Nenhum vídeo no intervalo selecionado",
|
||||
"noRangeSelected": "Sem definição final - playlist completa selecionada",
|
||||
"playlistUrlDescription": "Baixar todos os vídeos de uma playlist em lote",
|
||||
"positionLabel": "Item {{índice}} de {{total}}",
|
||||
"positionLabel": "Item {{index}} de {{total}}",
|
||||
"previewButton": "Visualizar lista de reprodução",
|
||||
"previewFailed": "Falha ao visualizar a playlist",
|
||||
"previewRequired": "Visualize a lista de reprodução antes de fazer o download.",
|
||||
"previewSummary": "Visualize os itens da lista de reprodução antes de fazer o download.",
|
||||
"previewRequired": "Visualize a lista de reprodução antes de fazer o download.",
|
||||
"range": "Intervalo (Opcional)",
|
||||
"resetToDefault": "Redefinir para padrão",
|
||||
"selectedRange": "Intervalo: {{início}}-{{fim}}",
|
||||
"selectedRange": "Intervalo: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}} selecionados",
|
||||
"downloadCurrentRange": "Baixar selecionados",
|
||||
"showingCount": "Exibindo {{count}} vídeos",
|
||||
"selectEntry": "Selecionar entrada {{index}}",
|
||||
"noEntriesSelected": "Nenhuma entrada selecionada",
|
||||
"startIndex": "Início (1)",
|
||||
"title": "Baixar Playlist",
|
||||
"totalVideos": "Total de vídeos: {{count}}",
|
||||
@@ -312,39 +370,61 @@
|
||||
"audio": "Preferências de Áudio",
|
||||
"browserForCookies": "Selecionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
||||
"browserForCookiesProfile": "Nome do perfil ou caminho",
|
||||
"browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.",
|
||||
"browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)",
|
||||
"browserForCookiesProfileInvalid": "O caminho do perfil não é válido. Escolha a pasta do perfil do navegador selecionado.",
|
||||
"browserForCookiesProfileInvalidPath": "Essa pasta não existe. Escolha uma pasta de perfil existente.",
|
||||
"browserForCookiesProfileInvalidProfile": "Nome do perfil não encontrado no local padrão do navegador.",
|
||||
"browserForCookiesProfileInvalidUnsupported": "Nenhum local de perfil padrão é conhecido para este navegador nesta plataforma.",
|
||||
"browserForCookiesProfileInvalidEmpty": "Digite um caminho de perfil para o navegador selecionado.",
|
||||
"cookiesFile": "Arquivo de cookies",
|
||||
"cookiesFileDescription": "Arquivo de cookies formatados do Netscape para carregar para autenticação",
|
||||
"clearCookiesFile": "Claro",
|
||||
"cookiesHelpTitle": "Usando cookies",
|
||||
"cookiesHelpBrowser": "Escolha seu navegador acima para reutilizar automaticamente a sessão de login.",
|
||||
"cookiesHelpFile": "Exporte um arquivo de cookies do Netscape (consulte as perguntas frequentes do yt-dlp) e selecione-o aqui quando necessário.",
|
||||
"cookiesHelpFaq": "Perguntas frequentes sobre cookies do yt-dlp",
|
||||
"openLinkError": "Falha ao abrir o link",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"chromium": "Chromium",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
"opera": "Opera",
|
||||
"safari": "Safari",
|
||||
"vivaldi": "Vivaldi",
|
||||
"whale": "Whale"
|
||||
},
|
||||
"clearConfigFile": "Claro",
|
||||
"clearCookiesFile": "Claro",
|
||||
"configFile": "Usar arquivo de configuração",
|
||||
"configFileDescription": "Arquivo de configuração personalizado para yt-dlp",
|
||||
"cookiesFile": "Arquivo de cookies",
|
||||
"cookiesFileDescription": "Arquivo de cookies formatados do Netscape para carregar para autenticação",
|
||||
"cookiesHelpBrowser": "Escolha seu navegador acima para reutilizar automaticamente a sessão de login.",
|
||||
"cookiesHelpFaq": "Perguntas frequentes sobre cookies do yt-dlp",
|
||||
"cookiesHelpFile": "Exporte um arquivo de cookies do Netscape (consulte as perguntas frequentes do yt-dlp) e selecione-o aqui quando necessário.",
|
||||
"cookiesHelpTitle": "Usando cookies",
|
||||
"clearConfigFile": "Claro",
|
||||
"dark": "Escuro",
|
||||
"description": "Configure suas preferências de download e configurações do aplicativo",
|
||||
"directorySelectError": "Falha ao selecionar diretório",
|
||||
"downloadPath": "Local de download",
|
||||
"downloadPathDescription": "Escolha onde salvar os arquivos baixados",
|
||||
"enableAnalytics": "Ajude a melhorar o VidBee",
|
||||
"enableAnalyticsDescription": "Compartilhe dados de uso anônimos para nos ajudar a entender como o aplicativo é usado e priorizar melhorias.",
|
||||
"fileSelectError": "Falha ao selecionar arquivo",
|
||||
"general": "Geral",
|
||||
"language": "Idioma",
|
||||
"languageDescription": "Escolha seu idioma preferido para a interface do aplicativo",
|
||||
"light": "Claro",
|
||||
"hideDockIcon": "Ocultar ícone do Dock",
|
||||
"hideDockIconDescription": "Remova o VidBee do Dock do macOS. \nUse a barra de menu ou o ícone da bandeja para reabrir o aplicativo.",
|
||||
"language": "Idioma",
|
||||
"launchAtLogin": "Lançar na inicialização",
|
||||
"launchAtLoginDescription": "Abra o VidBee automaticamente depois de fazer login no seu computador.",
|
||||
"launchAtLoginUnsupported": "A inicialização automática está disponível apenas no macOS e no Windows.",
|
||||
"light": "Claro",
|
||||
"enableAnalytics": "Ajude a melhorar o VidBee",
|
||||
"enableAnalyticsDescription": "Compartilhe dados de uso anônimos para nos ajudar a entender como o aplicativo é usado e priorizar melhorias.",
|
||||
"embedChapters": "Incorporar capítulos",
|
||||
"embedChaptersDescription": "Adicionar marcadores de capítulo ao arquivo quando disponíveis",
|
||||
"embedMetadata": "Incorporar metadados",
|
||||
"embedMetadataDescription": "Gravar título, artista e outros metadados quando disponíveis",
|
||||
"embedSubs": "Incorporar legendas",
|
||||
"embedSubsDescription": "Incorporar legendas no arquivo de vídeo (mp4, webm, mkv)",
|
||||
"embedThumbnail": "Incorporar miniatura",
|
||||
"embedThumbnailDescription": "Adicionar a miniatura como arte de capa",
|
||||
"maxConcurrentDownloads": "Número máximo de downloads ativos",
|
||||
"maxConcurrentDownloadsDescription": "Número máximo de downloads simultâneos",
|
||||
"none": "Nenhum",
|
||||
@@ -362,7 +442,6 @@
|
||||
"normal": "Normal",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"openLinkError": "Falha ao abrir o link",
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Servidor proxy para requisições de rede",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -371,8 +450,7 @@
|
||||
"showMoreFormats": "Mostrar mais opções de formato",
|
||||
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo.",
|
||||
"intervalDescription": "Com que frequência o VidBee verifica cada feed de assinatura (1 a 24 horas)."
|
||||
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo."
|
||||
},
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
@@ -384,6 +462,119 @@
|
||||
},
|
||||
"video": "Preferências de Vídeo"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Assinaturas",
|
||||
"subtitle": "{{count}} assinatura{{count, plural, one {} other {s}}}",
|
||||
"description": "Monitore automaticamente feeds RSS e enfileire novos downloads sem trabalho manual.",
|
||||
"defaults": {
|
||||
"title": "Padrões de automação",
|
||||
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
|
||||
"downloadDirectory": "Baixar diretório",
|
||||
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",
|
||||
"onlyLatest": "Baixe apenas o vídeo mais recente",
|
||||
"onlyLatestDescription": "Quando ativado, o VidBee ignora os itens mais antigos do backlog e captura apenas o upload mais recente."
|
||||
},
|
||||
"add": {
|
||||
"title": "Adicionar RSS",
|
||||
"description": "Cole um link de feed RSS. \nO VidBee detectará o feed automaticamente."
|
||||
},
|
||||
"fields": {
|
||||
"url": "URL do feed",
|
||||
"keywords": "Filtro de palavra-chave (separado por vírgula)",
|
||||
"tags": "Etiquetas automáticas",
|
||||
"customDirectory": "Diretório personalizado",
|
||||
"namingTemplate": "Modelo de nome de arquivo personalizado (somente arquivo)",
|
||||
"onlyLatest": "Baixe apenas o vídeo mais recente",
|
||||
"onlyLatestDescription": "Ignore os itens do backlog e busque apenas o upload mais recente deste feed.",
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Desabilitado",
|
||||
"onlyLatestShort": "Apenas o mais recente"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Adicionar",
|
||||
"refresh": "Atualizar",
|
||||
"edit": "Editar",
|
||||
"remove": "Remover",
|
||||
"save": "Salvar alterações",
|
||||
"selectDirectory": "Navegar",
|
||||
"enable": "Habilitar",
|
||||
"disable": "Desativar"
|
||||
},
|
||||
"items": {
|
||||
"title": "Últimos envios ({{count}})",
|
||||
"count": "{{count}} itens",
|
||||
"empty": "Nenhum item de feed recente encontrado.",
|
||||
"status": {
|
||||
"queued": "Na fila",
|
||||
"notQueued": "Não está na fila",
|
||||
"pending": "Pendente",
|
||||
"downloading": "Baixando",
|
||||
"processing": "Processamento",
|
||||
"completed": "Concluído",
|
||||
"error": "Fracassado",
|
||||
"cancelled": "Cancelado"
|
||||
},
|
||||
"fromChannel": "De {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "Status do download: {{status}}",
|
||||
"downloadPending": "Aguardando detalhes do download...",
|
||||
"notQueued": "Ainda não está na fila de download"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Abrir no navegador",
|
||||
"queue": "Adicionar à fila de download"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "Subscrição",
|
||||
"unknown": "Assinatura desconhecida",
|
||||
"noThumbnail": "Sem miniatura"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "Falha ao abrir o seletor de diretório.",
|
||||
"missingUrl": "Cole primeiro o link do canal.",
|
||||
"created": "Assinatura adicionada",
|
||||
"createError": "Falha ao adicionar assinatura.",
|
||||
"refreshStarted": "Atualização iniciada",
|
||||
"removed": "Assinatura removida",
|
||||
"updated": "Assinatura atualizada",
|
||||
"itemQueued": "Adicionado à fila de download",
|
||||
"itemAlreadyQueued": "Este vídeo já está na fila",
|
||||
"queueError": "Falha ao adicionar à fila de download.",
|
||||
"openLinkError": "Falha ao abrir o link do vídeo.",
|
||||
"resolveError": "Falha ao resolver o URL do feed RSS."
|
||||
},
|
||||
"detectedFeed": "Feed de {{platform}} detectado -> {{feed}}",
|
||||
"detecting": "Detectando feed...",
|
||||
"latestVideo": "Vídeo mais recente: {{title}}",
|
||||
"lastChecked": "Última verificação: {{time}}",
|
||||
"never": "Nunca",
|
||||
"empty": "Ainda não há assinaturas. \nAdicione seus canais favoritos para iniciar o download automático.",
|
||||
"edit": {
|
||||
"title": "Editar {{name}}",
|
||||
"description": "Ajuste filtros, tags e substituições para este feed."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"up-to-date": "Atualizado",
|
||||
"checking": "Verificando",
|
||||
"failed": "Fracassado",
|
||||
"idle": "Parado",
|
||||
"tooltip": {
|
||||
"updatedAt": "Atualizado: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "Assinaturas automatizadas com RSSHub",
|
||||
"description": "Combine VidBee com RSSHub para permitir assinaturas e downloads automatizados de várias plataformas. \nDepois de configurado, o VidBee é executado em segundo plano e baixa automaticamente os vídeos e conteúdos mais recentes.",
|
||||
"learnMore": "Saiba mais sobre RSSHub",
|
||||
"openDocs": "Abra a documentação do RSSHub",
|
||||
"hint": "Não tem um URL de feed RSS? \nUse o RSSHub para gerar feeds RSS para YouTube, Twitter e milhares de outras plataformas."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Suporta {{sites}} e mais.",
|
||||
"moreDescription": "A lista completa do yt-dlp é atualizada constantemente pela comunidade.",
|
||||
@@ -468,119 +659,5 @@
|
||||
},
|
||||
"popularSection": "Plataformas principais",
|
||||
"viewAll": "Ver todos os sites suportados"
|
||||
},
|
||||
"subscriptions": {
|
||||
"actions": {
|
||||
"add": "Adicionar",
|
||||
"disable": "Desativar",
|
||||
"edit": "Editar",
|
||||
"enable": "Habilitar",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "Remover",
|
||||
"save": "Salvar alterações",
|
||||
"selectDirectory": "Navegar"
|
||||
},
|
||||
"add": {
|
||||
"description": "Cole um link de feed RSS. \nO VidBee detectará o feed automaticamente.",
|
||||
"title": "Adicionar RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "Intervalo de verificação (horas)",
|
||||
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
|
||||
"downloadDirectory": "Baixar diretório",
|
||||
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",
|
||||
"onlyLatest": "Baixe apenas o vídeo mais recente",
|
||||
"onlyLatestDescription": "Quando ativado, o VidBee ignora os itens mais antigos do backlog e captura apenas o upload mais recente.",
|
||||
"title": "Padrões de automação"
|
||||
},
|
||||
"description": "Monitore automaticamente feeds RSS e enfileire novos downloads sem trabalho manual.",
|
||||
"detectedFeed": "Feed de {{plataforma}} detectado -> {{feed}}",
|
||||
"detecting": "Detectando feed...",
|
||||
"edit": {
|
||||
"description": "Ajuste filtros, tags e substituições para este feed.",
|
||||
"title": "Editar {{nome}}"
|
||||
},
|
||||
"empty": "Ainda não há assinaturas. \nAdicione seus canais favoritos para iniciar o download automático.",
|
||||
"fields": {
|
||||
"customDirectory": "Diretório personalizado",
|
||||
"disabled": "Desabilitado",
|
||||
"enabled": "Habilitado",
|
||||
"keywords": "Filtro de palavra-chave (separado por vírgula)",
|
||||
"namingTemplate": "Modelo de nome de arquivo personalizado (somente arquivo)",
|
||||
"onlyLatest": "Baixe apenas o vídeo mais recente",
|
||||
"onlyLatestDescription": "Ignore os itens do backlog e busque apenas o upload mais recente deste feed.",
|
||||
"onlyLatestShort": "Apenas o mais recente",
|
||||
"tags": "Etiquetas automáticas",
|
||||
"url": "URL do feed"
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
"open": "Abrir no navegador",
|
||||
"queue": "Adicionar à fila de download"
|
||||
},
|
||||
"count": "{{contar}} itens",
|
||||
"empty": "Nenhum item de feed recente encontrado.",
|
||||
"fromChannel": "De {{canal}}",
|
||||
"status": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"downloading": "Baixando",
|
||||
"error": "Fracassado",
|
||||
"notQueued": "Não está na fila",
|
||||
"pending": "Pendente",
|
||||
"processing": "Processamento",
|
||||
"queued": "Na fila"
|
||||
},
|
||||
"title": "Últimos envios ({{count}})",
|
||||
"tooltip": {
|
||||
"downloadPending": "Aguardando detalhes do download...",
|
||||
"downloadStatus": "Status do download: {{status}}",
|
||||
"notQueued": "Ainda não está na fila de download"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"noThumbnail": "Sem miniatura",
|
||||
"subscription": "Subscrição",
|
||||
"unknown": "Assinatura desconhecida"
|
||||
},
|
||||
"lastChecked": "Última verificação: {{time}}",
|
||||
"latestVideo": "Vídeo mais recente: {{title}}",
|
||||
"never": "Nunca",
|
||||
"notifications": {
|
||||
"createError": "Falha ao adicionar assinatura.",
|
||||
"created": "Assinatura adicionada",
|
||||
"directoryError": "Falha ao abrir o seletor de diretório.",
|
||||
"itemAlreadyQueued": "Este vídeo já está na fila",
|
||||
"itemQueued": "Adicionado à fila de download",
|
||||
"missingUrl": "Cole primeiro o link do canal.",
|
||||
"openLinkError": "Falha ao abrir o link do vídeo.",
|
||||
"queueError": "Falha ao adicionar à fila de download.",
|
||||
"refreshStarted": "Atualização iniciada",
|
||||
"removed": "Assinatura removida",
|
||||
"resolveError": "Falha ao resolver o URL do feed RSS.",
|
||||
"updated": "Assinatura atualizada"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"rssHub": {
|
||||
"description": "Combine VidBee com RSSHub para permitir assinaturas e downloads automatizados de várias plataformas. \nDepois de configurado, o VidBee é executado em segundo plano e baixa automaticamente os vídeos e conteúdos mais recentes.",
|
||||
"hint": "Não tem um URL de feed RSS? \nUse o RSSHub para gerar feeds RSS para YouTube, Twitter e milhares de outras plataformas.",
|
||||
"learnMore": "Saiba mais sobre RSSHub",
|
||||
"openDocs": "Abra a documentação do RSSHub",
|
||||
"title": "Assinaturas automatizadas com RSSHub"
|
||||
},
|
||||
"status": {
|
||||
"checking": "Verificando",
|
||||
"failed": "Fracassado",
|
||||
"idle": "Parado",
|
||||
"title": "Status",
|
||||
"tooltip": {
|
||||
"updatedAt": "Atualizado: {{hora}}"
|
||||
},
|
||||
"up-to-date": "Atualizado"
|
||||
},
|
||||
"subtitle": "{{count}} assinatura{{count, plural, one {} other {s}}}",
|
||||
"title": "Assinaturas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"documentationDescription": "Руководства, FAQ и общие рабочие процессы.",
|
||||
"feedback": "Обратная связь и проблемы",
|
||||
"feedbackDescription": "Поделитесь идеями или сообщите о проблемах на GitHub.",
|
||||
"githubIssues": "GitHub",
|
||||
"githubIssuesDescription": "Сообщайте об ошибках или предлагайте функции на GitHub.",
|
||||
"xFeedback": "Twitter",
|
||||
"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": "Введите URL видео",
|
||||
@@ -149,15 +149,17 @@
|
||||
"paste": "Вставить",
|
||||
"pastePlaylistUrl": "Нажмите, чтобы вставить ссылку на плейлист из буфера обмена [Ctrl + V]",
|
||||
"pasteUrl": "Нажмите, чтобы вставить URL видео или ID [Ctrl + V]",
|
||||
"pasteUrlButton": "Вставить URL",
|
||||
"preparing": "Подготовка...",
|
||||
"processing": "Обработка",
|
||||
"progress": "Прогресс",
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали",
|
||||
"selectAudioFormat": "Выбрать формат аудио",
|
||||
"selectDownloadType": "Выберите тип загрузки",
|
||||
"selectFormat": "Выбрать формат",
|
||||
"selectVideoFormat": "Выбрать формат видео",
|
||||
"startDownload": "Начать загрузку",
|
||||
"selectVideoFormat": "Выбрать формат видео",
|
||||
"singleVideo": "Одно видео",
|
||||
"speed": "Скорость",
|
||||
"title": "Название",
|
||||
@@ -193,8 +195,30 @@
|
||||
"formatNote": "Примечание формата",
|
||||
"protocol": "Протокол",
|
||||
"subscription": "Подписка"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Что-то пошло не так",
|
||||
"description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение или сообщите об этой проблеме, если она повторится.",
|
||||
"message": "Сообщение об ошибке",
|
||||
"unknownError": "Произошла неизвестная ошибка",
|
||||
"goHome": "На главную",
|
||||
"reload": "Перезагрузить приложение",
|
||||
"copyReport": "Копировать отчет об ошибке",
|
||||
"copied": "Скопировано!",
|
||||
"copySuccess": "Отчет об ошибке скопирован в буфер обмена",
|
||||
"copyFailed": "Не удалось скопировать отчет об ошибке",
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали",
|
||||
"stackTrace": "Трассировка стека",
|
||||
"componentStack": "Стек компонентов",
|
||||
"noStackTrace": "Нет доступной трассировки стека",
|
||||
"fullReport": "Полный отчет об ошибке",
|
||||
"helpText": "Если эта ошибка повторяется, скопируйте отчет выше и поделитесь им с командой поддержки. Контактные данные можно найти на странице «О программе»."
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Нажмите, чтобы скопировать детали",
|
||||
"clipboardEmpty": "Буфер обмена пуст",
|
||||
@@ -203,6 +227,7 @@
|
||||
"emptyUrl": "Пожалуйста, введите URL",
|
||||
"errorDetails": "Детали ошибки",
|
||||
"fetchInfoFailed": "Не удалось получить информацию о видео",
|
||||
"invalidUrl": "Содержимое буфера обмена не является допустимым URL",
|
||||
"networkError": "Произошла ошибка. Проверьте вашу сеть и используйте правильный URL",
|
||||
"pasteFromClipboard": "Не удалось вставить из буфера обмена"
|
||||
},
|
||||
@@ -210,10 +235,23 @@
|
||||
"clearCancelled": "Очистить отменённые",
|
||||
"clearCompleted": "Очистить завершённые",
|
||||
"clearErrors": "Очистить ошибки",
|
||||
"clearAll": "Очистить всю историю",
|
||||
"clearAllAction": "Очистить историю",
|
||||
"clearSelection": "Очистить выбор",
|
||||
"confirmClearAllTitle": "Очистить всю историю?",
|
||||
"confirmClearAllDescription": "Удалить {{count}} элементов из истории. Файлы останутся на диске.",
|
||||
"confirmDeleteSelectedTitle": "Удалить выбранные элементы?",
|
||||
"confirmDeleteSelectedDescription": "Удалить {{count}} элементов из истории. Файлы останутся на диске.",
|
||||
"alsoDeleteFiles": "Также удалить файлы",
|
||||
"confirmDeletePlaylistTitle": "Удалить историю плейлиста?",
|
||||
"confirmDeletePlaylistDescription": "Удалить {{count}} элементов из {{title}} и удалить их файлы.",
|
||||
"copyToClipboard": "Копировать в буфер обмена",
|
||||
"copyUrl": "Копировать URL",
|
||||
"date": "Дата",
|
||||
"deletePlaylist": "Удалить плейлист",
|
||||
"deleteSelected": "Удалить выбранные",
|
||||
"description": "Просмотр и управление историей загрузок",
|
||||
"doneSelecting": "Готово",
|
||||
"duration": "Длительность",
|
||||
"fileSize": "Размер файла",
|
||||
"filters": {
|
||||
@@ -229,7 +267,14 @@
|
||||
"openFileLocation": "Открыть местоположение файла",
|
||||
"openFolder": "Открыть папку",
|
||||
"openInBrowser": "Нажмите, чтобы открыть в браузере",
|
||||
"removeAction": "Удалить",
|
||||
"removeItem": "Удалить элемент",
|
||||
"select": "Выбрать",
|
||||
"selectAll": "Выбрать все",
|
||||
"selectVisible": "Выбрать видимые",
|
||||
"selectItem": "Выбрать элемент",
|
||||
"selectedCount": "Выбрано: {{count}}",
|
||||
"selectionSummary": "{{selected}} из {{total}} видимых выбрано",
|
||||
"stats": {
|
||||
"cancelled": "Отменённые",
|
||||
"completed": "Завершённые",
|
||||
@@ -258,9 +303,15 @@
|
||||
"downloadCompleted": "Загрузка завершена",
|
||||
"downloadFailed": "Загрузка не удалась",
|
||||
"downloadStarted": "Загрузка началась",
|
||||
"historyCleared": "История очищена",
|
||||
"historyClearFailed": "Не удалось очистить историю",
|
||||
"itemRemoved": "Элемент удалён",
|
||||
"itemsRemoved": "{{count}} элементов удалено",
|
||||
"itemsRemoveFailed": "Не удалось удалить выбранные элементы",
|
||||
"openFileFailed": "Не удалось открыть файл",
|
||||
"openFolderFailed": "Не удалось открыть папку",
|
||||
"playlistHistoryRemoved": "Плейлист удален, файлы удалены",
|
||||
"playlistHistoryRemoveFailed": "Не удалось удалить историю плейлиста",
|
||||
"removeFailed": "Не удалось удалить элемент",
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"urlCopied": "URL скопирован в буфер обмена",
|
||||
@@ -269,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "Плейлист",
|
||||
"clearPreview": "Очистить предпросмотр",
|
||||
"collapsedProgress": "Загрузка плейлиста: {{completed}} / {{total}} завершено",
|
||||
"comingSoon": "Функция загрузки плейлиста скоро появится!",
|
||||
"completed": "Плейлист загружен",
|
||||
"description": "Загрузить все видео из плейлиста или канала YouTube",
|
||||
@@ -284,7 +336,9 @@
|
||||
"folderFormat": "Формат имени папки для плейлистов",
|
||||
"foundVideos": "Найдено {{count}} видео в плейлисте",
|
||||
"groupActive": "{{count}} активных",
|
||||
"groupCollapse": "Свернуть",
|
||||
"groupErrors": "{{count}} ошибок",
|
||||
"groupExpand": "Развернуть",
|
||||
"groupSummary": "{{completed}} / {{total}} завершено",
|
||||
"linkLabel": "URL плейлиста",
|
||||
"noEntries": "В этом плейлисте не найдено видео",
|
||||
@@ -299,7 +353,11 @@
|
||||
"range": "Диапазон (необязательно)",
|
||||
"resetToDefault": "Сбросить на значения по умолчанию",
|
||||
"selectedRange": "Диапазон: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}} выбрано",
|
||||
"downloadCurrentRange": "Загрузить выбранное",
|
||||
"showingCount": "Показано {{count}} видео",
|
||||
"selectEntry": "Выбрать запись {{index}}",
|
||||
"noEntriesSelected": "Нет выбранных записей",
|
||||
"startIndex": "Начало (1)",
|
||||
"title": "Загрузить плейлист",
|
||||
"totalVideos": "Всего видео: {{count}}",
|
||||
@@ -312,6 +370,14 @@
|
||||
"audio": "Настройки аудио",
|
||||
"browserForCookies": "Выбрать браузер для использования cookie",
|
||||
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
|
||||
"browserForCookiesProfile": "Имя профиля или путь",
|
||||
"browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.",
|
||||
"browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)",
|
||||
"browserForCookiesProfileInvalid": "Путь профиля недействителен. Выберите папку профиля для выбранного браузера.",
|
||||
"browserForCookiesProfileInvalidPath": "Эта папка не существует. Выберите существующую папку профиля.",
|
||||
"browserForCookiesProfileInvalidProfile": "Имя профиля не найдено в стандартном расположении браузера.",
|
||||
"browserForCookiesProfileInvalidUnsupported": "Для этого браузера на этой платформе неизвестно стандартное расположение профиля.",
|
||||
"browserForCookiesProfileInvalidEmpty": "Введите путь профиля для выбранного браузера.",
|
||||
"cookiesFile": "Файл cookie",
|
||||
"cookiesFileDescription": "Файл cookie в формате Netscape для загрузки для аутентификации",
|
||||
"clearCookiesFile": "Очистить",
|
||||
@@ -323,9 +389,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 +408,7 @@
|
||||
"fileSelectError": "Не удалось выбрать файл",
|
||||
"general": "Общие",
|
||||
"language": "Язык",
|
||||
"languageDescription": "Выберите предпочтительный язык интерфейса приложения",
|
||||
"light": "Светлая",
|
||||
"hideDockIcon": "Скрыть иконку Dock",
|
||||
"hideDockIconDescription": "Удалить VidBee из Dock macOS. Используйте строку меню или иконку в трее, чтобы снова открыть приложение.",
|
||||
@@ -346,6 +417,14 @@
|
||||
"launchAtLoginUnsupported": "Автозапуск доступен только в macOS и Windows.",
|
||||
"enableAnalytics": "Помочь улучшить VidBee",
|
||||
"enableAnalyticsDescription": "Поделитесь анонимными данными об использовании, чтобы помочь нам понять, как используется приложение, и расставить приоритеты улучшений.",
|
||||
"embedChapters": "Встраивать главы",
|
||||
"embedChaptersDescription": "Добавлять маркеры глав в файл, если доступны",
|
||||
"embedMetadata": "Встраивать метаданные",
|
||||
"embedMetadataDescription": "Записывать название, исполнителя и другие метаданные, если доступны",
|
||||
"embedSubs": "Встраивать субтитры",
|
||||
"embedSubsDescription": "Встраивать субтитры в файл видео (mp4, webm, mkv)",
|
||||
"embedThumbnail": "Встраивать миниатюру",
|
||||
"embedThumbnailDescription": "Добавлять миниатюру как обложку",
|
||||
"maxConcurrentDownloads": "Максимальное количество активных загрузок",
|
||||
"maxConcurrentDownloadsDescription": "Максимальное количество одновременных загрузок",
|
||||
"none": "Нет",
|
||||
@@ -371,8 +450,7 @@
|
||||
"showMoreFormats": "Показать больше вариантов форматов",
|
||||
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.",
|
||||
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
|
||||
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла."
|
||||
},
|
||||
"system": "Системная",
|
||||
"theme": "Тема",
|
||||
@@ -393,7 +471,6 @@
|
||||
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
|
||||
"downloadDirectory": "Директория загрузки",
|
||||
"filenameTemplate": "Шаблон имени файла (только файл)",
|
||||
"checkInterval": "Интервал проверки (часы)",
|
||||
"onlyLatest": "Загружать только последнее видео",
|
||||
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"betaProgramDescription": "搶先獲得預覽版和即將發布的新功能。",
|
||||
"betaProgramTitle": "預覽通道",
|
||||
"description": "VidBee 是一個基於 Node.js 和 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}} 已推出。請從官方網站下載。",
|
||||
"updateDownloaded": "更新已下載,重新啟動以安裝",
|
||||
"updateDownloadedVersion": "下載更新 {{version}},重新啟動安裝",
|
||||
"updateError": "檢查更新失敗:{{error}}"
|
||||
"updateError": "檢查更新失敗:{{error}}",
|
||||
"unknownErrorFallback": "未知錯誤"
|
||||
},
|
||||
"preferencesDescription": "無需離開此頁即可調整更新設定。",
|
||||
"preferencesTitle": "快速切換",
|
||||
@@ -58,6 +51,12 @@
|
||||
"documentationDescription": "指南、常見問題和常見流程。",
|
||||
"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": "輸入影片連結",
|
||||
@@ -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": "來源網址",
|
||||
"videoCodec": "視頻編解碼器",
|
||||
"views": "意見",
|
||||
"width": "寬度"
|
||||
},
|
||||
"moreOptions": "更多選項",
|
||||
"noActiveDownloads": "暫無進行中的下載",
|
||||
"noAudio": "無音訊",
|
||||
"noHistory": "暫無下載歷史",
|
||||
"noItems": "未找到項目",
|
||||
"goToSettings": "前往“設置”",
|
||||
"oneClickDownload": "一鍵下載",
|
||||
"oneClickDownloadDescription": "使用預設設定直接下載,無需確認",
|
||||
"oneClickDownloadEnabled": "已啟用一鍵下載。下載將直接以默認設置開始。",
|
||||
@@ -176,15 +149,17 @@
|
||||
"paste": "貼上",
|
||||
"pastePlaylistUrl": "點擊從剪貼簿貼上播放清單連結 [Ctrl + V]",
|
||||
"pasteUrl": "點擊貼上影片連結或 ID [Ctrl + V]",
|
||||
"pasteUrlButton": "貼上網址",
|
||||
"preparing": "正在準備...",
|
||||
"processing": "處理中",
|
||||
"progress": "進度",
|
||||
"showDetails": "顯示詳情",
|
||||
"hideDetails": "隱藏詳細信息",
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectDownloadType": "選擇下載類型",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"startDownload": "開始下載",
|
||||
"showDetails": "顯示詳情",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"singleVideo": "單個影片",
|
||||
"speed": "速度",
|
||||
"title": "標題",
|
||||
@@ -194,7 +169,55 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "影片",
|
||||
"videoInfo": "影片資訊",
|
||||
"videoInfoUpdated": "影片資訊已更新"
|
||||
"videoInfoUpdated": "影片資訊已更新",
|
||||
"metadata": {
|
||||
"source": "來源",
|
||||
"playlist": "播放列表",
|
||||
"format": "格式",
|
||||
"quality": "品質",
|
||||
"codec": "編解碼器",
|
||||
"savedFile": "保存的文件",
|
||||
"url": "來源網址",
|
||||
"description": "描述",
|
||||
"views": "意見",
|
||||
"tags": "標籤",
|
||||
"downloadPath": "下載路徑",
|
||||
"createdAt": "創建於",
|
||||
"startedAt": "開始於",
|
||||
"completedAt": "完成於",
|
||||
"speed": "速度",
|
||||
"fileSize": "文件大小",
|
||||
"width": "寬度",
|
||||
"height": "高度",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "視頻編解碼器",
|
||||
"audioCodec": "音頻編解碼器",
|
||||
"formatNote": "格式註釋",
|
||||
"protocol": "協定",
|
||||
"subscription": "訂閱"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "發生錯誤",
|
||||
"description": "發生未預期的錯誤。請重新載入應用程式,若問題持續請回報。",
|
||||
"message": "錯誤訊息",
|
||||
"unknownError": "發生未知錯誤",
|
||||
"goHome": "回到首頁",
|
||||
"reload": "重新載入應用程式",
|
||||
"copyReport": "複製錯誤報告",
|
||||
"copied": "已複製!",
|
||||
"copySuccess": "錯誤報告已複製到剪貼簿",
|
||||
"copyFailed": "無法複製錯誤報告",
|
||||
"showDetails": "顯示詳細資訊",
|
||||
"hideDetails": "隱藏詳細資訊",
|
||||
"stackTrace": "堆疊追蹤",
|
||||
"componentStack": "元件堆疊",
|
||||
"noStackTrace": "沒有可用的堆疊追蹤",
|
||||
"fullReport": "完整錯誤報告",
|
||||
"helpText": "若此錯誤持續,請複製上方的錯誤報告並與支援團隊分享。聯絡資訊可在「關於」頁面找到。"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "點擊複製詳情",
|
||||
@@ -204,6 +227,7 @@
|
||||
"emptyUrl": "請輸入連結",
|
||||
"errorDetails": "錯誤詳情",
|
||||
"fetchInfoFailed": "取得影片資訊失敗",
|
||||
"invalidUrl": "剪貼簿內容不是有效的網址",
|
||||
"networkError": "發生錯誤。請檢查網路並確認連結正確",
|
||||
"pasteFromClipboard": "從剪貼簿貼上失敗"
|
||||
},
|
||||
@@ -211,10 +235,23 @@
|
||||
"clearCancelled": "清除已取消",
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除錯誤",
|
||||
"clearAll": "清除所有歷史紀錄",
|
||||
"clearAllAction": "清除歷史紀錄",
|
||||
"clearSelection": "清除選取",
|
||||
"confirmClearAllTitle": "清除所有歷史紀錄?",
|
||||
"confirmClearAllDescription": "從歷史紀錄移除 {{count}} 項。檔案仍會保留在磁碟上。",
|
||||
"confirmDeleteSelectedTitle": "移除選取項目?",
|
||||
"confirmDeleteSelectedDescription": "從歷史紀錄移除 {{count}} 項。檔案仍會保留在磁碟上。",
|
||||
"alsoDeleteFiles": "同時刪除檔案",
|
||||
"confirmDeletePlaylistTitle": "移除播放清單歷史紀錄?",
|
||||
"confirmDeletePlaylistDescription": "從 {{title}} 移除 {{count}} 項並刪除其檔案。",
|
||||
"copyToClipboard": "複製到剪貼簿",
|
||||
"copyUrl": "複製連結",
|
||||
"date": "日期",
|
||||
"deletePlaylist": "移除播放清單",
|
||||
"deleteSelected": "移除選取",
|
||||
"description": "檢視並管理下載歷史",
|
||||
"doneSelecting": "完成",
|
||||
"duration": "時長",
|
||||
"fileSize": "檔案大小",
|
||||
"filters": {
|
||||
@@ -230,7 +267,14 @@
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"openFolder": "開啟資料夾",
|
||||
"openInBrowser": "點擊在瀏覽器中開啟",
|
||||
"removeAction": "移除",
|
||||
"removeItem": "移除項目",
|
||||
"select": "選取",
|
||||
"selectAll": "全選",
|
||||
"selectVisible": "選取可見項目",
|
||||
"selectItem": "選取項目",
|
||||
"selectedCount": "已選取 {{count}} 項",
|
||||
"selectionSummary": "已選取可見項目 {{selected}} / {{total}}",
|
||||
"stats": {
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
@@ -248,9 +292,9 @@
|
||||
"about": "關於",
|
||||
"download": "下載",
|
||||
"playlist": "下載播放清單",
|
||||
"preferences": "偏好設定",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "訂閱",
|
||||
"preferences": "偏好設定",
|
||||
"supportedSites": "支援的網站",
|
||||
"theme": "主題:"
|
||||
},
|
||||
@@ -259,9 +303,15 @@
|
||||
"downloadCompleted": "下載完成",
|
||||
"downloadFailed": "下載失敗",
|
||||
"downloadStarted": "下載已開始",
|
||||
"historyCleared": "歷史紀錄已清除",
|
||||
"historyClearFailed": "清除歷史紀錄失敗",
|
||||
"itemRemoved": "項目已移除",
|
||||
"itemsRemoved": "已移除 {{count}} 項",
|
||||
"itemsRemoveFailed": "移除選取項目失敗",
|
||||
"openFileFailed": "開啟檔案失敗",
|
||||
"openFolderFailed": "開啟資料夾失敗",
|
||||
"playlistHistoryRemoved": "播放清單已移除並刪除檔案",
|
||||
"playlistHistoryRemoveFailed": "移除播放清單歷史紀錄失敗",
|
||||
"removeFailed": "移除項目失敗",
|
||||
"settingsSaved": "設定已儲存",
|
||||
"urlCopied": "連結已複製到剪貼簿",
|
||||
@@ -270,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "播放列表",
|
||||
"clearPreview": "清晰預覽",
|
||||
"collapsedProgress": "正在下載播放清單:{{completed}} / {{total}} 已完成",
|
||||
"comingSoon": "播放清單下載功能即將推出!",
|
||||
"completed": "播放清單已下載",
|
||||
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
||||
@@ -285,8 +336,10 @@
|
||||
"folderFormat": "播放清單資料夾命名格式",
|
||||
"foundVideos": "在播放清單中找到 {{count}} 個影片",
|
||||
"groupActive": "{{count}} 個活躍",
|
||||
"groupCollapse": "收合",
|
||||
"groupErrors": "{{count}} 失敗",
|
||||
"groupSummary": "{{已完成}} / {{總計}}已完成",
|
||||
"groupExpand": "展開",
|
||||
"groupSummary": "{{completed}} / {{total}} 已完成",
|
||||
"linkLabel": "播放清單連結",
|
||||
"noEntries": "在此播放列表中找不到視頻",
|
||||
"noEntriesInRange": "所選範圍內沒有視頻",
|
||||
@@ -295,12 +348,16 @@
|
||||
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
|
||||
"previewButton": "預覽播放列表",
|
||||
"previewFailed": "預覽播放列表失敗",
|
||||
"previewRequired": "下載前預覽播放列表。",
|
||||
"previewSummary": "下載前預覽播放列表項目。",
|
||||
"previewRequired": "下載前預覽播放列表。",
|
||||
"range": "範圍(可選)",
|
||||
"resetToDefault": "恢復預設",
|
||||
"selectedRange": "範圍:{{開始}}-{{結束}}",
|
||||
"selectedRange": "範圍:{{start}}-{{end}}",
|
||||
"selectedVideos": "已選取 {{count}} 項",
|
||||
"downloadCurrentRange": "下載選取項目",
|
||||
"showingCount": "顯示 {{count}} 個視頻",
|
||||
"selectEntry": "選取項目 {{index}}",
|
||||
"noEntriesSelected": "未選取任何項目",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "下載播放清單",
|
||||
"totalVideos": "視頻總數:{{count}}",
|
||||
@@ -313,39 +370,61 @@
|
||||
"audio": "音訊偏好",
|
||||
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
||||
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
||||
"browserForCookiesProfile": "設定檔名稱或路徑",
|
||||
"browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。",
|
||||
"browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)",
|
||||
"browserForCookiesProfileInvalid": "設定檔路徑無效。請選擇所選瀏覽器的設定檔資料夾。",
|
||||
"browserForCookiesProfileInvalidPath": "該資料夾不存在。請選擇現有的設定檔資料夾。",
|
||||
"browserForCookiesProfileInvalidProfile": "在預設瀏覽器位置找不到設定檔名稱。",
|
||||
"browserForCookiesProfileInvalidUnsupported": "此平台上該瀏覽器沒有已知的預設設定檔位置。",
|
||||
"browserForCookiesProfileInvalidEmpty": "請輸入所選瀏覽器的設定檔路徑。",
|
||||
"cookiesFile": "餅乾文件",
|
||||
"cookiesFileDescription": "要加載以進行身份驗證的 Netscape 格式的 cookie 文件",
|
||||
"clearCookiesFile": "清除",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。",
|
||||
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ)並在需要時在此處選擇它。",
|
||||
"cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答",
|
||||
"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 cookies 常見問題解答",
|
||||
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ)並在需要時在此處選擇它。",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"clearConfigFile": "清除",
|
||||
"dark": "深色",
|
||||
"description": "設定下載偏好和應用程式設定",
|
||||
"directorySelectError": "選擇目錄失敗",
|
||||
"downloadPath": "下載位置",
|
||||
"downloadPathDescription": "選擇儲存下載檔案的位置",
|
||||
"enableAnalytics": "幫助改進 VidBee",
|
||||
"enableAnalyticsDescription": "共享匿名使用數據,幫助我們了解應用程序的使用情況並確定改進的優先順序。",
|
||||
"fileSelectError": "選擇檔案失敗",
|
||||
"general": "一般",
|
||||
"language": "語言",
|
||||
"languageDescription": "選擇應用程式介面的偏好語言",
|
||||
"light": "淺色",
|
||||
"hideDockIcon": "隱藏 Dock 圖標",
|
||||
"hideDockIconDescription": "從 macOS Dock 中刪除 VidBee。使用菜單欄或託盤圖標重新打開應用程序。",
|
||||
"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": "無",
|
||||
@@ -363,7 +442,6 @@
|
||||
"normal": "標準",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "無法打開鏈接",
|
||||
"proxy": "代理伺服器",
|
||||
"proxyDescription": "網路請求的代理伺服器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -372,8 +450,7 @@
|
||||
"showMoreFormats": "顯示更多格式選項",
|
||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
|
||||
"intervalDescription": "VidBee 檢查每個訂閱源的頻率(1-24 小時)。"
|
||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。"
|
||||
},
|
||||
"system": "系統",
|
||||
"theme": "主題",
|
||||
@@ -385,6 +462,119 @@
|
||||
},
|
||||
"video": "影片偏好"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "訂閱",
|
||||
"subtitle": "{{count}} 訂閱{{count,複數,一個 {} 其它 {s}}}",
|
||||
"description": "自動監控 RSS 源並對新下載進行排隊,無需手動操作。",
|
||||
"defaults": {
|
||||
"title": "自動化默認值",
|
||||
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
|
||||
"downloadDirectory": "下載目錄",
|
||||
"filenameTemplate": "文件名模板(僅限文件)",
|
||||
"onlyLatest": "僅下載最新視頻",
|
||||
"onlyLatestDescription": "啟用後,VidBee 會跳過較舊的積壓項目,僅獲取最新上傳的項目。"
|
||||
},
|
||||
"add": {
|
||||
"title": "添加RSS",
|
||||
"description": "粘貼 RSS 源鏈接。 VidBee 將自動檢測提要。"
|
||||
},
|
||||
"fields": {
|
||||
"url": "提要網址",
|
||||
"keywords": "關鍵字過濾器(逗號分隔)",
|
||||
"tags": "自動標籤",
|
||||
"customDirectory": "自定義目錄",
|
||||
"namingTemplate": "自定義文件名模板(僅限文件)",
|
||||
"onlyLatest": "僅下載最新視頻",
|
||||
"onlyLatestDescription": "忽略積壓的項目並僅從此源中獲取最新上傳的內容。",
|
||||
"enabled": "啟用",
|
||||
"disabled": "殘疾人",
|
||||
"onlyLatestShort": "僅最新"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加",
|
||||
"refresh": "重新整理",
|
||||
"edit": "編輯",
|
||||
"remove": "消除",
|
||||
"save": "保存更改",
|
||||
"selectDirectory": "瀏覽",
|
||||
"enable": "使能夠",
|
||||
"disable": "禁用"
|
||||
},
|
||||
"items": {
|
||||
"title": "最新上傳 ({{count}})",
|
||||
"count": "{{count}} 件",
|
||||
"empty": "未找到最近的 Feed 項目。",
|
||||
"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。"
|
||||
},
|
||||
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "檢測飼料...",
|
||||
"latestVideo": "最新視頻:{{title}}",
|
||||
"lastChecked": "最後檢查時間:{{time}}",
|
||||
"never": "絕不",
|
||||
"empty": "還沒有訂閱。添加您喜愛的頻道以開始自動下載。",
|
||||
"edit": {
|
||||
"title": "編輯{{name}}",
|
||||
"description": "調整此提要的過濾器、標籤和覆蓋。"
|
||||
},
|
||||
"status": {
|
||||
"title": "地位",
|
||||
"up-to-date": "最新",
|
||||
"checking": "檢查",
|
||||
"failed": "失敗的",
|
||||
"idle": "閒置的",
|
||||
"tooltip": {
|
||||
"updatedAt": "更新時間:{{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "使用 RSSHub 自動訂閱",
|
||||
"description": "將 VidBee 與 RSSHub 結合起來,可以從各種平台實現自動訂閱和下載。設置完成後,VidBee 將在後台運行並自動下載最新的視頻和內容。",
|
||||
"learnMore": "了解有關 RSSHub 的更多信息",
|
||||
"openDocs": "打開 RSSHub 文檔",
|
||||
"hint": "沒有 RSS 源 URL?使用 RSSHub 為 YouTube、Twitter 和數千個其他平台生成 RSS 源。"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "支援 {{sites}} 等更多網站。",
|
||||
"moreDescription": "完整的 yt-dlp 清單由社群持續更新。",
|
||||
@@ -469,119 +659,5 @@
|
||||
},
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "檢視全部支援的網站"
|
||||
},
|
||||
"subscriptions": {
|
||||
"actions": {
|
||||
"add": "添加",
|
||||
"disable": "禁用",
|
||||
"edit": "編輯",
|
||||
"enable": "使能夠",
|
||||
"refresh": "重新整理",
|
||||
"remove": "消除",
|
||||
"save": "保存更改",
|
||||
"selectDirectory": "瀏覽"
|
||||
},
|
||||
"add": {
|
||||
"description": "粘貼 RSS 源鏈接。 VidBee 將自動檢測提要。",
|
||||
"title": "添加RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "檢查間隔(小時)",
|
||||
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
|
||||
"downloadDirectory": "下載目錄",
|
||||
"filenameTemplate": "文件名模板(僅限文件)",
|
||||
"onlyLatest": "僅下載最新視頻",
|
||||
"onlyLatestDescription": "啟用後,VidBee 會跳過較舊的積壓項目,僅獲取最新上傳的項目。",
|
||||
"title": "自動化默認值"
|
||||
},
|
||||
"description": "自動監控 RSS 源並對新下載進行排隊,無需手動操作。",
|
||||
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "檢測飼料...",
|
||||
"edit": {
|
||||
"description": "調整此提要的過濾器、標籤和覆蓋。",
|
||||
"title": "編輯{{name}}"
|
||||
},
|
||||
"empty": "還沒有訂閱。添加您喜愛的頻道以開始自動下載。",
|
||||
"fields": {
|
||||
"customDirectory": "自定義目錄",
|
||||
"disabled": "殘疾人",
|
||||
"enabled": "啟用",
|
||||
"keywords": "關鍵字過濾器(逗號分隔)",
|
||||
"namingTemplate": "自定義文件名模板(僅限文件)",
|
||||
"onlyLatest": "僅下載最新視頻",
|
||||
"onlyLatestDescription": "忽略積壓的項目並僅從此源中獲取最新上傳的內容。",
|
||||
"onlyLatestShort": "僅最新",
|
||||
"tags": "自動標籤",
|
||||
"url": "提要網址"
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
"open": "在瀏覽器中打開",
|
||||
"queue": "添加到下載隊列"
|
||||
},
|
||||
"count": "{{count}} 件",
|
||||
"empty": "未找到最近的 Feed 項目。",
|
||||
"fromChannel": "來自{{頻道}}",
|
||||
"status": {
|
||||
"cancelled": "取消",
|
||||
"completed": "完全的",
|
||||
"downloading": "正在下載",
|
||||
"error": "失敗的",
|
||||
"notQueued": "未排隊",
|
||||
"pending": "待辦的",
|
||||
"processing": "加工",
|
||||
"queued": "排隊"
|
||||
},
|
||||
"title": "最新上傳 ({{count}})",
|
||||
"tooltip": {
|
||||
"downloadPending": "等待下載詳細信息...",
|
||||
"downloadStatus": "下載狀態:{{status}}",
|
||||
"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 結合起來,可以從各種平台實現自動訂閱和下載。設置完成後,VidBee 將在後台運行並自動下載最新的視頻和內容。",
|
||||
"hint": "沒有 RSS 源 URL?使用 RSSHub 為 YouTube、Twitter 和數千個其他平台生成 RSS 源。",
|
||||
"learnMore": "了解有關 RSSHub 的更多信息",
|
||||
"openDocs": "打開 RSSHub 文檔",
|
||||
"title": "使用 RSSHub 自動訂閱"
|
||||
},
|
||||
"status": {
|
||||
"checking": "檢查",
|
||||
"failed": "失敗的",
|
||||
"idle": "閒置的",
|
||||
"title": "地位",
|
||||
"tooltip": {
|
||||
"updatedAt": "更新時間:{{時間}}"
|
||||
},
|
||||
"up-to-date": "最新"
|
||||
},
|
||||
"subtitle": "{{count}} 訂閱{{count,複數,一個 {} 其它 {s}}}",
|
||||
"title": "訂閱"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
|
||||
"betaProgramTitle": "预览通道",
|
||||
"description": "这是一个基于 Node.js 和 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": "指南、常见问题和常见流程。",
|
||||
"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": "输入视频链接",
|
||||
@@ -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": "来源网址",
|
||||
"videoCodec": "视频编解码器",
|
||||
"views": "意见",
|
||||
"width": "宽度"
|
||||
},
|
||||
"moreOptions": "更多选项",
|
||||
"noActiveDownloads": "暂无进行中的下载",
|
||||
"noAudio": "无音频",
|
||||
"noHistory": "暂无下载历史",
|
||||
"noItems": "未找到项目",
|
||||
"goToSettings": "前往“设置”",
|
||||
"oneClickDownload": "一键下载",
|
||||
"oneClickDownloadDescription": "使用默认设置直接下载,无需确认",
|
||||
"oneClickDownloadEnabled": "已启用一键下载。\n下载将直接以默认设置开始。",
|
||||
@@ -176,15 +149,17 @@
|
||||
"paste": "粘贴",
|
||||
"pastePlaylistUrl": "点击从剪贴板粘贴播放列表链接 [Ctrl + V]",
|
||||
"pasteUrl": "点击粘贴视频链接或 ID [Ctrl + V]",
|
||||
"pasteUrlButton": "粘贴链接",
|
||||
"preparing": "正在准备...",
|
||||
"processing": "处理中",
|
||||
"progress": "进度",
|
||||
"showDetails": "显示详情",
|
||||
"hideDetails": "隐藏详细信息",
|
||||
"selectAudioFormat": "选择音频格式",
|
||||
"selectDownloadType": "选择下载类型",
|
||||
"selectFormat": "选择格式",
|
||||
"selectVideoFormat": "选择视频格式",
|
||||
"startDownload": "开始下载",
|
||||
"showDetails": "显示详情",
|
||||
"selectVideoFormat": "选择视频格式",
|
||||
"singleVideo": "单个视频",
|
||||
"speed": "速度",
|
||||
"title": "标题",
|
||||
@@ -194,7 +169,55 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "视频",
|
||||
"videoInfo": "视频信息",
|
||||
"videoInfoUpdated": "视频信息已更新"
|
||||
"videoInfoUpdated": "视频信息已更新",
|
||||
"metadata": {
|
||||
"source": "来源",
|
||||
"playlist": "播放列表",
|
||||
"format": "格式",
|
||||
"quality": "质量",
|
||||
"codec": "编解码器",
|
||||
"savedFile": "保存的文件",
|
||||
"url": "来源网址",
|
||||
"description": "描述",
|
||||
"views": "意见",
|
||||
"tags": "标签",
|
||||
"downloadPath": "下载路径",
|
||||
"createdAt": "创建于",
|
||||
"startedAt": "开始于",
|
||||
"completedAt": "完成于",
|
||||
"speed": "速度",
|
||||
"fileSize": "文件大小",
|
||||
"width": "宽度",
|
||||
"height": "高度",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "视频编解码器",
|
||||
"audioCodec": "音频编解码器",
|
||||
"formatNote": "格式注释",
|
||||
"protocol": "协议",
|
||||
"subscription": "订阅"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "出了点问题",
|
||||
"description": "发生了意外错误。请尝试重新加载应用,若问题持续请报告。",
|
||||
"message": "错误信息",
|
||||
"unknownError": "发生未知错误",
|
||||
"goHome": "返回首页",
|
||||
"reload": "重新加载应用",
|
||||
"copyReport": "复制错误报告",
|
||||
"copied": "已复制!",
|
||||
"copySuccess": "错误报告已复制到剪贴板",
|
||||
"copyFailed": "复制错误报告失败",
|
||||
"showDetails": "显示详情",
|
||||
"hideDetails": "隐藏详情",
|
||||
"stackTrace": "堆栈跟踪",
|
||||
"componentStack": "组件堆栈",
|
||||
"noStackTrace": "没有可用的堆栈跟踪",
|
||||
"fullReport": "完整错误报告",
|
||||
"helpText": "如果此错误持续,请复制以上错误报告并与支持团队分享。联系信息可在“关于”页面找到。"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "点击复制详情",
|
||||
@@ -204,6 +227,7 @@
|
||||
"emptyUrl": "请输入链接",
|
||||
"errorDetails": "错误详情",
|
||||
"fetchInfoFailed": "获取视频信息失败",
|
||||
"invalidUrl": "剪贴板内容不是有效的 URL",
|
||||
"networkError": "发生错误。请检查网络并确认链接正确",
|
||||
"pasteFromClipboard": "从剪贴板粘贴失败"
|
||||
},
|
||||
@@ -211,10 +235,23 @@
|
||||
"clearCancelled": "清除已取消",
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除错误",
|
||||
"clearAll": "清除全部历史记录",
|
||||
"clearAllAction": "清除历史记录",
|
||||
"clearSelection": "清除选择",
|
||||
"confirmClearAllTitle": "清除所有历史记录?",
|
||||
"confirmClearAllDescription": "从历史记录中移除 {{count}} 项。文件仍保留在磁盘上。",
|
||||
"confirmDeleteSelectedTitle": "移除所选项?",
|
||||
"confirmDeleteSelectedDescription": "从历史记录中移除 {{count}} 项。文件仍保留在磁盘上。",
|
||||
"alsoDeleteFiles": "同时删除文件",
|
||||
"confirmDeletePlaylistTitle": "移除播放列表历史记录?",
|
||||
"confirmDeletePlaylistDescription": "从 {{title}} 移除 {{count}} 项并删除其文件。",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyUrl": "复制链接",
|
||||
"date": "日期",
|
||||
"deletePlaylist": "移除播放列表",
|
||||
"deleteSelected": "移除所选",
|
||||
"description": "查看并管理下载历史",
|
||||
"doneSelecting": "完成",
|
||||
"duration": "时长",
|
||||
"fileSize": "文件大小",
|
||||
"filters": {
|
||||
@@ -230,7 +267,14 @@
|
||||
"openFileLocation": "打开文件位置",
|
||||
"openFolder": "打开文件夹",
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"removeAction": "移除",
|
||||
"removeItem": "移除项目",
|
||||
"select": "选择",
|
||||
"selectAll": "全选",
|
||||
"selectVisible": "选择可见项",
|
||||
"selectItem": "选择条目",
|
||||
"selectedCount": "已选择 {{count}} 项",
|
||||
"selectionSummary": "已选择可见项 {{selected}} / {{total}}",
|
||||
"stats": {
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
@@ -248,9 +292,9 @@
|
||||
"about": "关于",
|
||||
"download": "下载",
|
||||
"playlist": "下载播放列表",
|
||||
"preferences": "偏好设置",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "订阅",
|
||||
"preferences": "偏好设置",
|
||||
"supportedSites": "支持的网站",
|
||||
"theme": "主题:"
|
||||
},
|
||||
@@ -259,9 +303,15 @@
|
||||
"downloadCompleted": "下载完成",
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadStarted": "下载已开始",
|
||||
"historyCleared": "历史记录已清除",
|
||||
"historyClearFailed": "清除历史记录失败",
|
||||
"itemRemoved": "项目已移除",
|
||||
"itemsRemoved": "已移除 {{count}} 项",
|
||||
"itemsRemoveFailed": "移除所选项失败",
|
||||
"openFileFailed": "打开文件失败",
|
||||
"openFolderFailed": "打开文件夹失败",
|
||||
"playlistHistoryRemoved": "已移除播放列表并删除文件",
|
||||
"playlistHistoryRemoveFailed": "移除播放列表历史记录失败",
|
||||
"removeFailed": "移除项目失败",
|
||||
"settingsSaved": "设置已保存",
|
||||
"urlCopied": "链接已复制到剪贴板",
|
||||
@@ -270,6 +320,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "播放列表",
|
||||
"clearPreview": "清晰预览",
|
||||
"collapsedProgress": "正在下载播放列表:已完成 {{completed}} / {{total}}",
|
||||
"comingSoon": "播放列表下载功能即将推出!",
|
||||
"completed": "播放列表已下载",
|
||||
"description": "下载 YouTube 播放列表或频道中的全部视频",
|
||||
@@ -285,7 +336,9 @@
|
||||
"folderFormat": "播放列表文件夹命名格式",
|
||||
"foundVideos": "在播放列表中找到 {{count}} 个视频",
|
||||
"groupActive": "{{count}} 个活跃",
|
||||
"groupCollapse": "折叠",
|
||||
"groupErrors": "{{count}} 失败",
|
||||
"groupExpand": "展开",
|
||||
"groupSummary": "{{completed}} / {{total}}已完成",
|
||||
"linkLabel": "播放列表链接",
|
||||
"noEntries": "在此播放列表中找不到视频",
|
||||
@@ -295,12 +348,16 @@
|
||||
"positionLabel": "第 {{index}} 项,共 {{total}} 项",
|
||||
"previewButton": "预览播放列表",
|
||||
"previewFailed": "预览播放列表失败",
|
||||
"previewRequired": "下载前预览播放列表。",
|
||||
"previewSummary": "下载前预览播放列表项目。",
|
||||
"previewRequired": "下载前预览播放列表。",
|
||||
"range": "范围(可选)",
|
||||
"resetToDefault": "恢复默认",
|
||||
"selectedRange": "范围:{{start}}-{{end}}",
|
||||
"selectedVideos": "已选择 {{count}} 项",
|
||||
"downloadCurrentRange": "下载所选",
|
||||
"showingCount": "显示 {{count}} 个视频",
|
||||
"selectEntry": "选择条目 {{index}}",
|
||||
"noEntriesSelected": "未选择任何条目",
|
||||
"startIndex": "开始(1)",
|
||||
"title": "下载播放列表",
|
||||
"totalVideos": "视频总数:{{count}}",
|
||||
@@ -313,39 +370,61 @@
|
||||
"audio": "音频偏好",
|
||||
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
||||
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
||||
"browserForCookiesProfile": "配置文件名称或路径",
|
||||
"browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。",
|
||||
"browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)",
|
||||
"browserForCookiesProfileInvalid": "配置文件路径无效。请选择所选浏览器的配置文件文件夹。",
|
||||
"browserForCookiesProfileInvalidPath": "该文件夹不存在。请选择现有的配置文件文件夹。",
|
||||
"browserForCookiesProfileInvalidProfile": "在默认浏览器位置未找到配置文件名称。",
|
||||
"browserForCookiesProfileInvalidUnsupported": "此平台上该浏览器没有已知的默认配置文件位置。",
|
||||
"browserForCookiesProfileInvalidEmpty": "请输入所选浏览器的配置文件路径。",
|
||||
"cookiesFile": "饼干文件",
|
||||
"cookiesFileDescription": "要加载以进行身份验证的 Netscape 格式的 cookie 文件",
|
||||
"clearCookiesFile": "清除",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"cookiesHelpBrowser": "选择上面的浏览器以自动重用其登录会话。",
|
||||
"cookiesHelpFile": "导出 Netscape cookies 文件(请参阅 yt-dlp FAQ)并在需要时在此处选择它。",
|
||||
"cookiesHelpFaq": "打开 yt-dlp cookies 常见问题解答",
|
||||
"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 cookies 常见问题解答",
|
||||
"cookiesHelpFile": "导出 Netscape cookies 文件(请参阅 yt-dlp FAQ)并在需要时在此处选择它。",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"clearConfigFile": "清除",
|
||||
"dark": "深色",
|
||||
"description": "配置下载偏好和应用设置",
|
||||
"directorySelectError": "选择目录失败",
|
||||
"downloadPath": "下载位置",
|
||||
"downloadPathDescription": "选择保存下载文件的位置",
|
||||
"enableAnalytics": "帮助改进 VidBee",
|
||||
"enableAnalyticsDescription": "共享匿名使用数据,帮助我们了解应用程序的使用情况并确定改进的优先顺序。",
|
||||
"fileSelectError": "选择文件失败",
|
||||
"general": "通用",
|
||||
"language": "语言",
|
||||
"languageDescription": "选择应用界面的首选语言",
|
||||
"light": "浅色",
|
||||
"hideDockIcon": "隐藏 Dock 图标",
|
||||
"hideDockIconDescription": "从 macOS Dock 中删除 VidBee。\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": "无",
|
||||
@@ -363,7 +442,6 @@
|
||||
"normal": "标准",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "无法打开链接",
|
||||
"proxy": "代理",
|
||||
"proxyDescription": "网络请求的代理服务器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -372,8 +450,7 @@
|
||||
"showMoreFormats": "显示更多格式选项",
|
||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
|
||||
"intervalDescription": "VidBee 检查每个订阅源的频率(1-24 小时)。"
|
||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。"
|
||||
},
|
||||
"system": "系统",
|
||||
"theme": "主题",
|
||||
@@ -385,6 +462,119 @@
|
||||
},
|
||||
"video": "视频偏好"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "订阅",
|
||||
"subtitle": "{{count}} 订阅{{count,复数,一个 {} 其它 {s}}}",
|
||||
"description": "自动监控 RSS 源并对新下载进行排队,无需手动操作。",
|
||||
"defaults": {
|
||||
"title": "自动化默认值",
|
||||
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
|
||||
"downloadDirectory": "下载目录",
|
||||
"filenameTemplate": "文件名模板(仅限文件)",
|
||||
"onlyLatest": "仅下载最新视频",
|
||||
"onlyLatestDescription": "启用后,VidBee 会跳过较旧的积压项目,仅获取最新上传的项目。"
|
||||
},
|
||||
"add": {
|
||||
"title": "添加RSS",
|
||||
"description": "粘贴 RSS 源链接。 \nVidBee 将自动检测提要。"
|
||||
},
|
||||
"fields": {
|
||||
"url": "提要网址",
|
||||
"keywords": "关键字过滤器(逗号分隔)",
|
||||
"tags": "自动标签",
|
||||
"customDirectory": "自定义目录",
|
||||
"namingTemplate": "自定义文件名模板(仅限文件)",
|
||||
"onlyLatest": "仅下载最新视频",
|
||||
"onlyLatestDescription": "忽略积压的项目并仅从此源中获取最新上传的内容。",
|
||||
"enabled": "启用",
|
||||
"disabled": "残疾人",
|
||||
"onlyLatestShort": "仅最新"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
"edit": "编辑",
|
||||
"remove": "消除",
|
||||
"save": "保存更改",
|
||||
"selectDirectory": "浏览",
|
||||
"enable": "使能够",
|
||||
"disable": "禁用"
|
||||
},
|
||||
"items": {
|
||||
"title": "最新上传 ({{count}})",
|
||||
"count": "{{count}} 件",
|
||||
"empty": "未找到最近的 Feed 项目。",
|
||||
"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。"
|
||||
},
|
||||
"detectedFeed": "检测到 {{platform}} feed -> {{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?\n使用 RSSHub 为 YouTube、Twitter 和数千个其他平台生成 RSS 源。"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "支持 {{sites}} 等更多网站。",
|
||||
"moreDescription": "完整的 yt-dlp 列表由社区持续更新。",
|
||||
@@ -469,119 +659,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": "检测到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "检测饲料...",
|
||||
"edit": {
|
||||
"description": "调整此提要的过滤器、标签和覆盖。",
|
||||
"title": "编辑{{name}}"
|
||||
},
|
||||
"empty": "还没有订阅。\n添加您喜爱的频道以开始自动下载。",
|
||||
"fields": {
|
||||
"customDirectory": "自定义目录",
|
||||
"disabled": "残疾人",
|
||||
"enabled": "启用",
|
||||
"keywords": "关键字过滤器(逗号分隔)",
|
||||
"namingTemplate": "自定义文件名模板(仅限文件)",
|
||||
"onlyLatest": "仅下载最新视频",
|
||||
"onlyLatestDescription": "忽略积压的项目并仅从此源中获取最新上传的内容。",
|
||||
"onlyLatestShort": "仅最新",
|
||||
"tags": "自动标签",
|
||||
"url": "提要网址"
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
"open": "在浏览器中打开",
|
||||
"queue": "添加到下载队列"
|
||||
},
|
||||
"count": "{{count}} 件",
|
||||
"empty": "未找到最近的 Feed 项目。",
|
||||
"fromChannel": "来自{{channel}}",
|
||||
"status": {
|
||||
"cancelled": "取消",
|
||||
"completed": "完全的",
|
||||
"downloading": "正在下载",
|
||||
"error": "失败的",
|
||||
"notQueued": "未排队",
|
||||
"pending": "待办的",
|
||||
"processing": "加工",
|
||||
"queued": "排队"
|
||||
},
|
||||
"title": "最新上传 ({{count}})",
|
||||
"tooltip": {
|
||||
"downloadPending": "等待下载详细信息...",
|
||||
"downloadStatus": "下载状态:{{status}}",
|
||||
"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?\n使用 RSSHub 为 YouTube、Twitter 和数千个其他平台生成 RSS 源。",
|
||||
"learnMore": "了解有关 RSSHub 的更多信息",
|
||||
"openDocs": "打开 RSSHub 文档",
|
||||
"title": "使用 RSSHub 自动订阅"
|
||||
},
|
||||
"status": {
|
||||
"checking": "检查",
|
||||
"failed": "失败的",
|
||||
"idle": "闲置的",
|
||||
"title": "地位",
|
||||
"tooltip": {
|
||||
"updatedAt": "更新时间:{{time}}"
|
||||
},
|
||||
"up-to-date": "最新"
|
||||
},
|
||||
"subtitle": "{{count}} 订阅{{count,复数,一个 {} 其它 {s}}}",
|
||||
"title": "订阅"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,82 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './i18n'
|
||||
import { logger } from './lib/logger'
|
||||
|
||||
// Setup global error handlers
|
||||
setupGlobalErrorHandlers()
|
||||
|
||||
// Get app version asynchronously
|
||||
let appVersion: string | undefined
|
||||
if (window?.api && window.electron?.ipcRenderer) {
|
||||
import('./lib/ipc')
|
||||
.then(({ ipcServices }) => ipcServices.app.getVersion())
|
||||
.then((version) => {
|
||||
appVersion = version
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Failed to get app version for error reporting:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function setupGlobalErrorHandlers(): void {
|
||||
// Handle uncaught JavaScript errors
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error('Uncaught error:', event.error)
|
||||
|
||||
if (window?.api) {
|
||||
try {
|
||||
window.api.send('error:renderer', {
|
||||
error: {
|
||||
name: event.error?.name || 'Error',
|
||||
message: event.error?.message || event.message || 'Unknown error',
|
||||
stack: event.error?.stack || event.filename
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
context: {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
version: appVersion,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to send error to main process:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logger.error('Unhandled promise rejection:', event.reason)
|
||||
|
||||
if (window?.api) {
|
||||
try {
|
||||
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))
|
||||
|
||||
window.api.send('error:renderer', {
|
||||
error: {
|
||||
name: error.name || 'UnhandledPromiseRejection',
|
||||
message: error.message || String(event.reason),
|
||||
stack: error.stack
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { FeedbackLinkButtons, useAppInfo } from '@renderer/components/feedback/FeedbackLinks'
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
@@ -17,15 +12,16 @@ import {
|
||||
FileText,
|
||||
Github,
|
||||
Link as LinkIcon,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Twitter
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
|
||||
|
||||
interface AboutResource {
|
||||
icon: LucideIcon
|
||||
@@ -43,34 +39,28 @@ type LatestVersionState =
|
||||
| null
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [updateReady] = useAtom(updateReadyAtom)
|
||||
const [updateAvailableState] = useAtom(updateAvailableAtom)
|
||||
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||
const { appVersion, osVersion } = useAppInfo()
|
||||
const appVersionLabel = appVersion || '—'
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
const fetchAppVersion = async () => {
|
||||
try {
|
||||
const version = await ipcServices.app.getVersion()
|
||||
if (isActive) {
|
||||
setAppVersion(version)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get app version:', error)
|
||||
}
|
||||
if (!updateAvailableState.available) {
|
||||
return
|
||||
}
|
||||
|
||||
void fetchAppVersion()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: updateAvailableState.version ?? ''
|
||||
})
|
||||
}, [updateAvailableState.available, updateAvailableState.version])
|
||||
|
||||
// Listen for update events only in About page
|
||||
useEffect(() => {
|
||||
@@ -83,11 +73,15 @@ export function About() {
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
toast.success(i18n.t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: versionLabel
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: versionLabel
|
||||
})
|
||||
// Reset download progress when new update is available
|
||||
setUpdateDownloadProgress(0)
|
||||
}
|
||||
@@ -113,7 +107,7 @@ export function About() {
|
||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
}
|
||||
}, [t])
|
||||
}, [i18n, setUpdateAvailable])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
@@ -135,6 +129,10 @@ export function About() {
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: result.version
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
@@ -145,7 +143,11 @@ export function About() {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
version: result.version ?? appVersionLabel
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: false,
|
||||
version: undefined
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -159,6 +161,10 @@ export function About() {
|
||||
openShareUrl('https://vidbee.org/download/')
|
||||
}
|
||||
|
||||
const handleRestartToUpdate = () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
try {
|
||||
toast.info(t('about.notifications.checkingUpdates'))
|
||||
@@ -170,6 +176,10 @@ export function About() {
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: true,
|
||||
version: result.version
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
@@ -180,7 +190,11 @@ export function About() {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
version: result.version ?? appVersionLabel
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: false,
|
||||
version: undefined
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -202,13 +216,13 @@ export function About() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const openShareUrl = (url: string) => {
|
||||
const openShareUrl = useCallback((url: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleShareTwitter = () => {
|
||||
openShareUrl(shareLinks.twitter)
|
||||
@@ -242,6 +256,8 @@ export function About() {
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||
const shouldShowCheckUpdates =
|
||||
!updateAvailableState.available && latestVersionState?.status !== 'available'
|
||||
|
||||
const aboutResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
@@ -258,20 +274,6 @@ export function About() {
|
||||
description: t('about.resources.changelogDescription'),
|
||||
actionLabel: t('about.actions.view'),
|
||||
href: 'https://github.com/nexmoe/VidBee/releases'
|
||||
},
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
description: t('about.resources.githubIssuesDescription'),
|
||||
actionLabel: t('about.actions.feedback'),
|
||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
description: t('about.resources.discordDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
],
|
||||
[t]
|
||||
@@ -282,69 +284,89 @@ export function About() {
|
||||
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
</Badge>
|
||||
{latestVersionState ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{latestVersionBadgeText ? (
|
||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||
) : null}
|
||||
{latestVersionStatusText ? (
|
||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||
{latestVersionStatusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{updateDownloadProgress !== null && (
|
||||
<div className="space-y-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('about.downloadingUpdate')}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{updateDownloadProgress.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={updateDownloadProgress} className="h-2" />
|
||||
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersionLabel })}
|
||||
</Badge>
|
||||
{latestVersionState ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{latestVersionBadgeText ? (
|
||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||
) : null}
|
||||
{latestVersionStatusText ? (
|
||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||
{latestVersionStatusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href="https://github.com/nexmoe/vidbee"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('about.actions.openRepo')}
|
||||
>
|
||||
<Github className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
{updateReady.ready ? (
|
||||
<Button
|
||||
onClick={handleRestartToUpdate}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t('about.notifications.restartNowAction')}
|
||||
</Button>
|
||||
) : null}
|
||||
{latestVersionState?.status === 'available' ? (
|
||||
<Button
|
||||
onClick={handleGoToDownload}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('about.actions.goToDownload')}
|
||||
</Button>
|
||||
) : null}
|
||||
{shouldShowCheckUpdates ? (
|
||||
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href="https://github.com/nexmoe/vidbee"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('about.actions.openRepo')}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{latestVersionState?.status === 'available' ? (
|
||||
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('about.actions.goToDownload')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleCheckForUpdates} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{updateDownloadProgress !== null && (
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<div className="space-y-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('about.downloadingUpdate')}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{updateDownloadProgress.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={updateDownloadProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
|
||||
@@ -361,7 +383,6 @@ export function About() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.shareDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -408,6 +429,29 @@ export function About() {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col divide-y">
|
||||
{/* Feedback section - merged into one row */}
|
||||
<div className="flex items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('about.resources.feedbackDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FeedbackLinkButtons
|
||||
appInfo={{ appVersion, osVersion }}
|
||||
issueTitle="[bug]: "
|
||||
buttonClassName="gap-2"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Other resources */}
|
||||
{aboutResources.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
|
||||
@@ -7,17 +7,9 @@ interface HomeProps {
|
||||
|
||||
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col min-h-0">
|
||||
<div
|
||||
className="container mx-auto max-w-7xl p-6 w-full h-full flex flex-col min-h-0"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedDownloadHistory
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,42 +18,75 @@ import {
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||
import type { OneClickQualityPreset } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
import { logger } from '../lib/logger'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
const clampSubscriptionInterval = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 3
|
||||
const normalizeProfileInput = (value: string) => value.trim().replace(/^['"]|['"]$/g, '')
|
||||
|
||||
const parseBrowserCookiesSetting = (value: string | undefined) => {
|
||||
if (!value || value === 'none') {
|
||||
return { browser: 'none', profile: '' }
|
||||
}
|
||||
return Math.min(24, Math.max(1, parsed))
|
||||
|
||||
const separatorIndex = value.indexOf(':')
|
||||
if (separatorIndex === -1) {
|
||||
return { browser: value, profile: '' }
|
||||
}
|
||||
|
||||
const browser = value.slice(0, separatorIndex).trim()
|
||||
const profile = normalizeProfileInput(value.slice(separatorIndex + 1))
|
||||
return { browser: browser || 'none', profile }
|
||||
}
|
||||
|
||||
const buildBrowserCookiesSetting = (browser: string, profile: string) => {
|
||||
const trimmedBrowser = browser.trim()
|
||||
if (!trimmedBrowser || trimmedBrowser === 'none') {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const trimmedProfile = normalizeProfileInput(profile)
|
||||
return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const [activeTab, setActiveTab] = useState<string>('general')
|
||||
const [browserProfileValidation, setBrowserProfileValidation] = useState<{
|
||||
valid: boolean
|
||||
reason?: string
|
||||
}>({ valid: false })
|
||||
const lastAutoDetectBrowser = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
try {
|
||||
loadSettings()
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Failed to load settings:', error)
|
||||
}
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlatform = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
logger.error('Failed to get platform info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,61 +95,61 @@ export function Settings() {
|
||||
|
||||
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
) => {
|
||||
await saveSetting({ key, value })
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
}
|
||||
const handleSettingChange = useCallback(
|
||||
async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => {
|
||||
try {
|
||||
await saveSetting({ key, value })
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Failed to change setting', { key, value, error })
|
||||
toast.error(t('settings.saveError') || 'Failed to save setting')
|
||||
}
|
||||
},
|
||||
[saveSetting, t]
|
||||
)
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
await handleSettingChange('downloadPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
logger.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectConfigFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('configPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select file:', error)
|
||||
logger.error('Failed to select file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCookiesFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('cookiesPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select cookies file:', error)
|
||||
logger.error('Failed to select cookies file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCookiesFaq = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
await ipcServices.fs.openExternal(
|
||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to open cookies FAQ:', error)
|
||||
logger.error('Failed to open cookies FAQ:', error)
|
||||
toast.error(t('settings.openLinkError'))
|
||||
}
|
||||
}
|
||||
@@ -131,6 +164,111 @@ export function Settings() {
|
||||
await handleSettingChange('theme', value)
|
||||
}
|
||||
|
||||
const languageOptions = languageList
|
||||
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
|
||||
const currentLanguage =
|
||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||
const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies)
|
||||
const browserForCookiesValue = parsedBrowserCookies.browser
|
||||
const browserCookiesProfileValue = parsedBrowserCookies.profile
|
||||
const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(
|
||||
browserForCookiesValue,
|
||||
browserCookiesProfileValue
|
||||
)
|
||||
const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0
|
||||
const showBrowserProfileCheck = hasBrowserProfileValue && browserProfileValidation.valid
|
||||
const showBrowserProfileWarning = hasBrowserProfileValue && !browserProfileValidation.valid
|
||||
const getBrowserProfileWarningMessage = (reason?: string) => {
|
||||
switch (reason) {
|
||||
case 'pathNotFound':
|
||||
return t('settings.browserForCookiesProfileInvalidPath')
|
||||
case 'profileNotFound':
|
||||
return t('settings.browserForCookiesProfileInvalidProfile')
|
||||
case 'browserUnsupported':
|
||||
return t('settings.browserForCookiesProfileInvalidUnsupported')
|
||||
case 'empty':
|
||||
return t('settings.browserForCookiesProfileInvalidEmpty')
|
||||
default:
|
||||
return t('settings.browserForCookiesProfileInvalid')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.browserForCookies !== normalizedBrowserCookiesSetting) {
|
||||
void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting)
|
||||
}
|
||||
}, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies])
|
||||
|
||||
useEffect(() => {
|
||||
const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue
|
||||
const shouldAutoDetect =
|
||||
browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue)
|
||||
|
||||
if (!shouldAutoDetect) {
|
||||
lastAutoDetectBrowser.current = browserForCookiesValue
|
||||
return
|
||||
}
|
||||
|
||||
const detectProfilePath = async () => {
|
||||
try {
|
||||
const detectedPath =
|
||||
await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue)
|
||||
const nextProfileValue = detectedPath || ''
|
||||
if (nextProfileValue !== browserCookiesProfileValue) {
|
||||
const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue)
|
||||
await handleSettingChange('browserForCookies', nextValue)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Failed to detect browser profile path:', error)
|
||||
} finally {
|
||||
lastAutoDetectBrowser.current = browserForCookiesValue
|
||||
}
|
||||
}
|
||||
|
||||
void detectProfilePath()
|
||||
}, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) {
|
||||
setBrowserProfileValidation({ valid: false, reason: 'empty' })
|
||||
return
|
||||
}
|
||||
|
||||
let isActive = true
|
||||
|
||||
const validateProfilePath = async () => {
|
||||
try {
|
||||
const result = await ipcServices.browserCookies.validateBrowserProfilePath(
|
||||
browserForCookiesValue,
|
||||
browserCookiesProfileValue
|
||||
)
|
||||
if (isActive) {
|
||||
setBrowserProfileValidation(result)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' })
|
||||
}
|
||||
logger.error('[Settings] Failed to validate browser profile path:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void validateProfilePath()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue])
|
||||
|
||||
const handleLanguageChange = async (value: LanguageCode) => {
|
||||
if (activeLanguageCode === value) {
|
||||
return
|
||||
}
|
||||
|
||||
await saveSetting({ key: 'language', value })
|
||||
await i18nInstance.changeLanguage(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
<div className="container mx-auto max-w-4xl p-6 space-y-6">
|
||||
@@ -139,7 +277,12 @@ export function Settings() {
|
||||
<p className="text-muted-foreground">{t('settings.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value)
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
|
||||
@@ -185,6 +328,53 @@ export function Settings() {
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.language')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={currentLanguage.value}
|
||||
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={currentLanguage.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`${currentLanguage.flag} rounded-xs text-base`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((option) => {
|
||||
const isActive = option.value === currentLanguage.value
|
||||
return (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={isActive ? 'font-semibold bg-muted' : undefined}
|
||||
>
|
||||
<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>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -311,46 +501,92 @@ export function Settings() {
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.showMoreFormatsDescription')}</ItemDescription>
|
||||
<ItemTitle>{t('settings.embedSubs')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.showMoreFormats}
|
||||
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
|
||||
checked={settings.embedSubs ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedSubs', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedSubs:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemSeparator />
|
||||
|
||||
{platform !== 'darwin' && (
|
||||
<>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t('settings.subscriptionDefaults.intervalDescription')}
|
||||
</ItemDescription>
|
||||
<ItemTitle>{t('settings.embedMetadata')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={settings.subscriptionCheckIntervalHours}
|
||||
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
|
||||
onBlur={(event) =>
|
||||
void handleSettingChange(
|
||||
'subscriptionCheckIntervalHours',
|
||||
clampSubscriptionInterval(event.target.value)
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
<Switch
|
||||
checked={settings.embedMetadata ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedMetadata', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedMetadata:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedChapters')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedChapters ?? true}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedChapters', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedChapters:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||
@@ -359,23 +595,45 @@ export function Settings() {
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.maxConcurrentDownloads.toString()}
|
||||
onValueChange={(value) =>
|
||||
handleSettingChange('maxConcurrentDownloads', Number(value))
|
||||
{(() => {
|
||||
try {
|
||||
const maxConcurrent = settings.maxConcurrentDownloads ?? 5
|
||||
const maxConcurrentStr = maxConcurrent.toString()
|
||||
return (
|
||||
<Select
|
||||
value={maxConcurrentStr}
|
||||
onValueChange={(value) => {
|
||||
try {
|
||||
const numValue = Number(value)
|
||||
handleSettingChange('maxConcurrentDownloads', numValue)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error changing max concurrent downloads:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error rendering max concurrent downloads select:',
|
||||
error
|
||||
)
|
||||
return <div>Error loading max concurrent downloads setting</div>
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -387,34 +645,28 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={settings.proxy}
|
||||
onChange={(e) => handleSettingChange('proxy', e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('configPath', '')}
|
||||
disabled={!settings.configPath}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
try {
|
||||
const proxyValue = settings.proxy ?? ''
|
||||
return (
|
||||
<Input
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={proxyValue}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
handleSettingChange('proxy', e.target.value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing proxy:', error)
|
||||
}
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering proxy input:', error)
|
||||
return <div>Error loading proxy setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
@@ -426,27 +678,126 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="chromium">
|
||||
{t('settings.browserOptions.chromium')}
|
||||
</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(() => {
|
||||
try {
|
||||
return (
|
||||
<Select
|
||||
value={browserForCookiesValue}
|
||||
onValueChange={(value) => {
|
||||
try {
|
||||
const nextValue = buildBrowserCookiesSetting(value, '')
|
||||
handleSettingChange('browserForCookies', nextValue)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing browser for cookies:', error)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">
|
||||
{t('settings.browserOptions.chrome')}
|
||||
</SelectItem>
|
||||
<SelectItem value="chromium">
|
||||
{t('settings.browserOptions.chromium')}
|
||||
</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">
|
||||
{t('settings.browserOptions.edge')}
|
||||
</SelectItem>
|
||||
<SelectItem value="safari">
|
||||
{t('settings.browserOptions.safari')}
|
||||
</SelectItem>
|
||||
<SelectItem value="brave">
|
||||
{t('settings.browserOptions.brave')}
|
||||
</SelectItem>
|
||||
<SelectItem value="opera">
|
||||
{t('settings.browserOptions.opera')}
|
||||
</SelectItem>
|
||||
<SelectItem value="vivaldi">
|
||||
{t('settings.browserOptions.vivaldi')}
|
||||
</SelectItem>
|
||||
<SelectItem value="whale">
|
||||
{t('settings.browserOptions.whale')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering browser for cookies select:', error)
|
||||
return <div>Error loading browser for cookies setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent className="basis-full">
|
||||
<ItemTitle>{t('settings.browserForCookiesProfile')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t('settings.browserForCookiesProfileDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="basis-full">
|
||||
{(() => {
|
||||
try {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder={t('settings.browserForCookiesProfilePlaceholder')}
|
||||
value={browserCookiesProfileValue}
|
||||
onChange={(event) => {
|
||||
try {
|
||||
const newProfileValue = event.target.value
|
||||
const nextValue = buildBrowserCookiesSetting(
|
||||
browserForCookiesValue,
|
||||
newProfileValue
|
||||
)
|
||||
handleSettingChange('browserForCookies', nextValue)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error changing browser cookies profile:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={browserForCookiesValue === 'none'}
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
{showBrowserProfileCheck ? (
|
||||
<CheckCircle2
|
||||
className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-emerald-500"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{showBrowserProfileWarning ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="absolute right-3 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getBrowserProfileWarningMessage(browserProfileValidation.reason)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error rendering browser cookies profile input:',
|
||||
error
|
||||
)
|
||||
return <div>Error loading browser cookies profile setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -458,17 +809,35 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
||||
disabled={!settings.cookiesPath}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
try {
|
||||
const cookiesPathValue = settings.cookiesPath ?? ''
|
||||
return (
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={cookiesPathValue} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
try {
|
||||
void handleSettingChange('cookiesPath', '')
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error clearing cookies path:', error)
|
||||
}
|
||||
}}
|
||||
disabled={!cookiesPathValue}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering cookies file input:', error)
|
||||
return <div>Error loading cookies file setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -477,12 +846,10 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground text-sm leading-normal">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||
@@ -490,6 +857,46 @@ export function Settings() {
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{(() => {
|
||||
try {
|
||||
const configPathValue = settings.configPath ?? ''
|
||||
return (
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={configPathValue} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
try {
|
||||
void handleSettingChange('configPath', '')
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error clearing config path:', error)
|
||||
}
|
||||
}}
|
||||
disabled={!configPathValue}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering config file input:', error)
|
||||
return <div>Error loading config file setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -499,10 +906,26 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.enableAnalytics}
|
||||
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
||||
/>
|
||||
{(() => {
|
||||
try {
|
||||
const analyticsValue = settings.enableAnalytics ?? true
|
||||
return (
|
||||
<Switch
|
||||
checked={analyticsValue}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('enableAnalytics', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing enable analytics:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering enable analytics switch:', error)
|
||||
return <div>Error loading enable analytics setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@renderer/components/ui/context-menu'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
@@ -288,7 +289,7 @@ export function Subscriptions() {
|
||||
|
||||
const handleOpenRSSHubDocs = useCallback(async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
@@ -319,12 +320,12 @@ export function Subscriptions() {
|
||||
}, [selectedTab, sortedSubscriptions])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
{/* Channel Tabs Header */}
|
||||
<div className="">
|
||||
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<div className="flex flex-row pr-6 pb-6 pl-6">
|
||||
<ScrollArea className="w-auto overflow-y-auto">
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-auto">
|
||||
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0 px-6">
|
||||
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0">
|
||||
{/* Subscription Channel Tabs */}
|
||||
{sortedSubscriptions.map((subscription) => (
|
||||
<SubscriptionTab
|
||||
@@ -336,66 +337,70 @@ export function Subscriptions() {
|
||||
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
|
||||
/>
|
||||
))}
|
||||
{/* Add RSS Button */}
|
||||
<Button
|
||||
className="flex h-auto w-20 flex-col items-center gap-1 rounded-2xl px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||
variant="ghost"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{t('subscriptions.add.title')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add RSS Button */}
|
||||
<Button
|
||||
className="flex h-auto w-20 flex-col items-center gap-1 rounded-2xl px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||
variant="ghost"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{t('subscriptions.add.title')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="overflow-y-auto ">
|
||||
{/* Content Area */}
|
||||
<div className="relative space-y-8 p-6 pt-0">
|
||||
<section className="space-y-4">
|
||||
{sortedSubscriptions.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : !selectedTab ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayedSubscriptions.map((subscription) => (
|
||||
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RSSHub Info Card */}
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t('subscriptions.rssHub.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="gap-2"
|
||||
>
|
||||
{t('subscriptions.rssHub.openDocs')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative space-y-8 p-6">
|
||||
<section className="space-y-4">
|
||||
{sortedSubscriptions.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : !selectedTab ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayedSubscriptions.map((subscription) => (
|
||||
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RSSHub Info Card */}
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t('subscriptions.rssHub.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="gap-2"
|
||||
>
|
||||
{t('subscriptions.rssHub.openDocs')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SubscriptionFormDialog
|
||||
mode="add"
|
||||
|
||||
@@ -24,6 +24,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
status: item.status,
|
||||
progress: undefined,
|
||||
error: item.error,
|
||||
ytDlpCommand: item.ytDlpCommand,
|
||||
downloadPath: item.downloadPath,
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
|
||||
21
src/renderer/src/store/update.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
type UpdateReadyState = {
|
||||
ready: boolean
|
||||
version?: string
|
||||
}
|
||||
|
||||
type UpdateAvailableState = {
|
||||
available: boolean
|
||||
version?: string
|
||||
}
|
||||
|
||||
export const updateReadyAtom = atom<UpdateReadyState>({
|
||||
ready: false,
|
||||
version: undefined
|
||||
})
|
||||
|
||||
export const updateAvailableAtom = atom<UpdateAvailableState>({
|
||||
available: false,
|
||||
version: undefined
|
||||
})
|
||||
@@ -11,15 +11,24 @@ export const videoInfoLoadingAtom = atom<boolean>(false)
|
||||
// Error state for video info
|
||||
export const videoInfoErrorAtom = atom<string | null>(null)
|
||||
|
||||
// Last yt-dlp command used for video info
|
||||
export const videoInfoCommandAtom = atom<string | null>(null)
|
||||
|
||||
// Fetch video info
|
||||
export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
|
||||
set(videoInfoLoadingAtom, true)
|
||||
set(videoInfoErrorAtom, null)
|
||||
set(videoInfoCommandAtom, null)
|
||||
set(currentVideoInfoAtom, null)
|
||||
|
||||
try {
|
||||
const info = await ipcServices.download.getVideoInfo(url)
|
||||
set(currentVideoInfoAtom, info)
|
||||
const result = await ipcServices.download.getVideoInfoWithCommand(url)
|
||||
set(videoInfoCommandAtom, result.ytDlpCommand)
|
||||
if (result.info) {
|
||||
set(currentVideoInfoAtom, result.info)
|
||||
return
|
||||
}
|
||||
set(videoInfoErrorAtom, result.error || 'Failed to fetch video info')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
set(videoInfoErrorAtom, errorMessage)
|
||||
@@ -32,4 +41,5 @@ export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
|
||||
export const clearVideoInfoAtom = atom(null, (_get, set) => {
|
||||
set(currentVideoInfoAtom, null)
|
||||
set(videoInfoErrorAtom, null)
|
||||
set(videoInfoCommandAtom, null)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface VideoFormat {
|
||||
tbr?: number
|
||||
quality?: number
|
||||
protocol?: string // http, https, m3u8, m3u8_native, etc.
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
@@ -33,6 +34,12 @@ export interface VideoInfo {
|
||||
uploader?: string
|
||||
}
|
||||
|
||||
export interface VideoInfoCommandResult {
|
||||
info?: VideoInfo
|
||||
ytDlpCommand: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
percent: number
|
||||
currentSpeed?: string
|
||||
@@ -54,11 +61,12 @@ export interface DownloadItem {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
status: DownloadStatus
|
||||
progress?: DownloadProgress
|
||||
error?: string
|
||||
speed?: string
|
||||
ytDlpCommand?: string
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
@@ -99,7 +107,7 @@ export interface DownloadHistoryItem {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
status: DownloadStatus
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
@@ -108,6 +116,7 @@ export interface DownloadHistoryItem {
|
||||
downloadedAt: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
ytDlpCommand?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
@@ -127,11 +136,10 @@ export interface DownloadHistoryItem {
|
||||
|
||||
export interface DownloadOptions {
|
||||
url: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
format?: string
|
||||
audioFormat?: string
|
||||
extractFormat?: string
|
||||
extractQuality?: string
|
||||
audioFormatIds?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
@@ -253,7 +261,6 @@ export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
export interface AppSettings {
|
||||
downloadPath: string
|
||||
showMoreFormats: boolean
|
||||
maxConcurrentDownloads: number
|
||||
browserForCookies: string
|
||||
cookiesPath: string
|
||||
@@ -270,15 +277,17 @@ export interface AppSettings {
|
||||
launchAtLogin: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
embedSubs: boolean
|
||||
embedThumbnail: boolean
|
||||
embedMetadata: boolean
|
||||
embedChapters: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
downloadPath: '',
|
||||
showMoreFormats: false,
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
@@ -295,6 +304,9 @@ export const defaultSettings: AppSettings = {
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
subscriptionCheckIntervalHours: 3,
|
||||
enableAnalytics: true
|
||||
enableAnalytics: true,
|
||||
embedSubs: true,
|
||||
embedThumbnail: false,
|
||||
embedMetadata: true,
|
||||
embedChapters: true
|
||||
}
|
||||
|
||||
81
src/shared/utils/format-preferences.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { AppSettings, OneClickQualityPreset } from '../types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
bad: 128,
|
||||
worst: 96
|
||||
}
|
||||
|
||||
const dedupe = (candidates: Array<string | undefined>): string[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (seen.has(candidate)) continue
|
||||
seen.add(candidate)
|
||||
result.push(candidate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
||||
settings.oneClickQuality ?? 'best'
|
||||
|
||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||
if (preset === 'worst') {
|
||||
return dedupe(['worstaudio', 'bestaudio'])
|
||||
}
|
||||
|
||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
||||
}
|
||||
|
||||
export const buildVideoFormatPreference = (settings: AppSettings): string => {
|
||||
const preset = getQualityPreset(settings)
|
||||
|
||||
if (preset === 'worst') {
|
||||
return 'worstvideo+worstaudio/worst/best'
|
||||
}
|
||||
|
||||
const maxHeight = qualityPresetToVideoHeight[preset]
|
||||
const videoCandidates = dedupe([
|
||||
maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,
|
||||
'bestvideo'
|
||||
])
|
||||
|
||||
const audioSelectors = buildAudioSelectors(preset)
|
||||
const combinations: string[] = []
|
||||
|
||||
for (const video of videoCandidates) {
|
||||
for (const audio of audioSelectors) {
|
||||
combinations.push(`${video}+${audio}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioSelectors.includes('none')) {
|
||||
for (const video of videoCandidates) {
|
||||
combinations.push(video)
|
||||
}
|
||||
} else {
|
||||
combinations.push('bestvideo+bestaudio')
|
||||
}
|
||||
|
||||
combinations.push('best')
|
||||
|
||||
return dedupe(combinations).join('/')
|
||||
}
|
||||
|
||||
export const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
||||
return dedupe([...selectors, 'best']).join('/')
|
||||
}
|
||||