Compare commits

..

73 Commits

Author SHA1 Message Date
Nexmoe
1ee717888f chore: release v1.0.2 2025-12-06 16:46:10 +08:00
Nexmoe
c7815eb81a feat: register vidbee protocol and respect vidbee:// paths in images (#35) 2025-12-06 16:45:48 +08:00
Nexmoe
3946b5ef2f feat: resolve ~ in config paths before passing to engine (#34) 2025-11-29 11:10:16 +08:00
Nexmoe
ef99eb1f59 feat: add Chromium option closes #32 (#33) 2025-11-22 19:28:20 +08:00
Nexmoe
31607bd947 chore: release v1.0.1 2025-11-16 17:08:13 +08:00
Nexmoe
452132ce16 feat(i18n): add comprehensive i18n (#31)
* feat(i18n): add comprehensive i18n

* fix(i18n): unify zh locale variable names to English keys for templating
2025-11-16 12:30:53 +08:00
Nexmoe
f84f3ef02a feat(settings): add auto-launch handling and UI toggle support (#29) 2025-11-15 16:16:16 +08:00
Nexmoe
6c3f070797 chore: release v1.0.0 2025-11-15 12:30:16 +08:00
Nexmoe
62a339e5b9 feat: rss-subscriptions (#28)
* feat(subscriptions): add subscription atoms and types for feeds and rules

* refactor(types): remove obsolete format/quality/codec fields and related logic to simplify download and avoid redundant storage

* feat: add download_history schema, migration, and tag utils

* refactor(dialog): convert to functional wrappers, simplify overlay and

* feat(rss): add enclosure support, refine thumbnail lookup and UI labels
feat(settings): add toggleable anonymous analytics collection and loader script

* feat(settings): add toggleable anonymous analytics collection and loader script

* feat(i18n): add subscriptions UI strings and import downloads types

* refactor(subscriptions): remove legacy status fields and migrate items ordering

* feat: add Radix HoverCard dependency and wrap tabs with HoverCard for

* feat(db): move download history schema into migrate + drizzle config

* feat(subscriptions): add item queueing UI and IPC handler

* feat(downloads): normalize saved filenames, improve candidates and UI display
2025-11-15 12:29:31 +08:00
Nexmoe
f1fd40176f feat(ui): make sidebar a draggable title bar and opt out buttons from drag region (#26) 2025-11-15 12:06:59 +08:00
Nexmoe
e5d8629ba3 feat(ui): make sidebar a draggable title bar and opt out buttons from drag region (#24) 2025-11-11 22:27:25 +08:00
Nexmoe
3375f94848 feat(images): add RemoteImage with caching and loading hooks, and (#25) 2025-11-11 22:23:58 +08:00
Nexmoe
9f7206abfe feat(history): migrate from electron-store to SQLite with drizzle and (#21) 2025-11-10 22:04:52 +08:00
Nexmoe
334c7426c2 feat(sites): show site icons and update supported sites layout (#23)
Modified SupportedSites.tsx:
- Added ImageWithPlaceholder import and IPC service import to fetch site icons.
- Implemented SiteIcon component that loads site icon via ipcServices.app.getSiteIcon and handles errors/cancellation.
- Updated layout: adjusted grid to include lg:grid-cols-3 and restructured list items into anchor cards with icon, label, external link icon, and description with truncation.
These changes are focused on UI improvements for the SupportedSites page and fetching site icons from the main process.
2025-11-10 21:22:59 +08:00
Nexmoe
bd7f4a433c chore: optimize ui and save structure (#19)
* feat: persist and use saved file name for downloads

* feat(settings): ensure and migrate download directory to VidBee/Downloads

* feat: format download dates with locale date and time for clarity

* feat(files): try multiple candidate file paths for file ops and DRY logic

* fix(download): handle multi-path file deletion and warn on failure
2025-11-08 21:28:51 +08:00
Nexmoe
27287238aa feat: rewrite README header and intro with badges and (#20) 2025-11-08 18:56:57 +08:00
Nexmoe
486767fca9 chore: optimize update function (#18)
* chore: add zip target with arm64 and x64 architectures to dmg/targets

* feat(updates): move update UI handling to About page and add progress UI and i18n key
2025-11-08 18:29:26 +08:00
Nexmoe
e22fe7679a chore: release v0.3.5 2025-11-08 13:45:05 +08:00
Nexmoe
7c2e526c49 feat(ui): add one-click texts, file existence checks, clipboard copy (#15)
* feat(ui): add one-click texts, file existence checks, clipboard copy

* refactor(ui): remove unused clearCompleted and adjust card bg
2025-11-08 13:44:28 +08:00
Nexmoe
2f5776d1e0 feat(theme): update primary/accent colors to new OKLCH values (#17) 2025-11-08 13:41:05 +08:00
Nexmoe
7901fc7e82 feat(settings): remove 'auto' preset and default to 'best' to (#16) 2025-11-08 13:40:17 +08:00
Nexmoe
612800bef2 chore: release v0.3.4 2025-11-03 20:20:41 +08:00
Nexmoe
41b58a6f70 feat(format-selector): exclude HLS, sort and improve labels for formats (#13) 2025-11-03 20:13:18 +08:00
Nexmoe
1a8e26a847 feat(update): add download action and guide users to download page when (#12)
* feat(update): add download action and guide users to download page when

* fix(updater): always initialize auto-updater in non-prod builds for testing
2025-11-02 19:35:49 +08:00
Nexmoe
0bcddddc37 chore: release v0.3.3 2025-11-02 17:59:39 +08:00
Nexmoe
99163865e2 fix(release): update release script to ensure main branch is checked out and up-to-date before running checks 2025-11-02 17:59:30 +08:00
Nexmoe
40113a2b53 feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and (#11)
* feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and

* fix(download-engine): let yt-dlp merge format to avoid codec conflicts and use actual format for filenames

* chore(ci): replace monolithic job with platform-specific reusable jobs

* chore(ci): run build matrix for windows, macos, linux in one to

* feat(ci): remove platform matrix and platform gating to simplify build

* chore(ci): pin github-translate-action to specific commit to ensure
2025-11-02 17:42:33 +08:00
Nexmoe
75c8b19f31 feat: add GitHub Actions workflow for translation automation
Create a new workflow to automate translation tasks for issues, comments, discussions, and pull requests. This includes permissions for writing to issues and discussions, and utilizes the github-translate-action for title modifications.
2025-10-31 20:27:59 +08:00
Nexmoe
2a28b0db85 chore: release v0.3.2 2025-10-31 20:21:46 +08:00
Nexmoe
ca4b851ba4 Update mac build script to output separate mac packages (#9)
* build: produce separate mac binaries

* chore(build): add artifactName for mac builds to include arch in file name
2025-10-31 20:20:51 +08:00
Nexmoe
0742dac057 chore: release v0.3.1 2025-10-30 22:36:50 +08:00
Nexmoe
9d2e35c322 chore: update maintainer email in electron-builder configuration 2025-10-30 22:35:42 +08:00
Nexmoe
526bd994d8 feat(updates): add auto-update notifications and handlers (#8)
Add update-related UI and IPC handling to App. Integrate
i18n and sonner to show localized toast notifications update
lifecycle events (available, download started, progress, downloaded,
errors, and restart prompt). Use a ref to guard against concurrent
download attempts and read auto-update preference from settings atom.
Wire IPC event names and services, and ensure graceful error handling
and user-triggered download/restart actions.

This enables better user feedback for updates and supports both
automatic and manual update flows.
2025-10-30 22:25:02 +08:00
Nexmoe
37c1f22aee Improve Linux compatibility (#6)
* Improve Linux compatibility

* Run CI on Linux builds

* Update release workflow for Linux builds
2025-10-30 21:36:00 +08:00
Nexmoe
10a97b1f4a support of macOS x64 packing (#7) 2025-10-30 21:31:42 +08:00
Nexmoe
11eaaf0f88 chore: release v0.3.0 2025-10-29 20:34:05 +08:00
Nexmoe
512b9d1175 feat: playlist (#2)
* feat: implement playlist download feature with UI components, enhance download service for playlist handling, and update translations for playlist-related terms

* refactor: replace console logs with log.info for auto-updater events and enhance zh-TW translations with new keys and updates
2025-10-29 20:32:27 +08:00
Nexmoe
a1307bdd0b feat: add hide-dock setting and fix about text handling (#3)
- Add "hideDockIcon" setting label and description to English locale,
  enabling users to remove VidBee from the macOS Dock and rely on the
  menu bar/tray icon.
- Apply dock visibility changes when settings change, when settings
  are loaded, and after settings are reset by calling
  applyDockVisibility from the settings service.
- Update About page to use about.description for share text and display
  content, replacing the removed tagline key usage.
- Remove unused "tagline" entries from multiple locale files (en, fr,
  it, pt, ja, zh) to keep translations in sync with UI text changes.

These changes add OS integration for hiding the dock icon and ensure UI
copy is consistent and correctly internationalized.
2025-10-29 20:32:03 +08:00
Nexmoe
06132a0704 chore: release v0.2.2 2025-10-27 22:01:44 +08:00
Nexmoe
a7e4eb865b feat: add cookies file support in settings and download engine, enhance i18n with new cookie-related translations 2025-10-27 22:01:11 +08:00
Nexmoe
5246ab8369 feat: implement version comparison for updates, enhance TitleBar component with platform support, and improve language handling in settings 2025-10-27 20:23:29 +08:00
Nexmoe
baa887da2e chore: release v0.2.1 2025-10-26 12:52:50 +08:00
Nexmoe
be40f087ad feat: enhance i18n support by dynamically loading translations and updating popular sites with localized labels 2025-10-25 17:36:59 +08:00
Nexmoe
ad62bd07aa feat: enhance About page with latest version check and status display 2025-10-25 16:27:44 +08:00
Nexmoe
365b9c0660 feat: add flag-icons package and improve language handling in sidebar and i18n 2025-10-25 15:59:03 +08:00
Nexmoe
a1f5e0480c Merge branch 'main' of https://github.com/nexmoe/VidBee 2025-10-25 15:31:16 +08:00
Nexmoe
00a7950e6a feat: update en.json with new follow author section and improved notifications 2025-10-25 15:31:12 +08:00
Nexmoe
598ed9c964 docs: add macOS installation notes to README for resolving "file is damaged" error 2025-10-25 14:44:47 +08:00
Nexmoe
2b3edab4eb chore: update CI workflow to run on Windows and download yt-dlp binaries 2025-10-25 11:34:58 +08:00
Nexmoe
d0bdfce803 chore: remove draft option from release workflow 2025-10-25 11:31:09 +08:00
Nexmoe
17a4568492 chore: enable release notes generation in workflow 2025-10-25 11:30:51 +08:00
Nexmoe
840a432b7f chore: release v0.2.0 2025-10-25 11:30:03 +08:00
Nexmoe
cc17e29fbe chore: macos icon 2025-10-25 11:25:22 +08:00
Nexmoe
dd5f0a729b feat: test for mac 2025-10-25 11:06:40 +08:00
Nexmoe
9d72caee44 chore: release v0.1.8 2025-10-24 22:07:15 +08:00
Nexmoe
661165bd65 chore: add script to check yt-dlp binary existence for cross-platform builds 2025-10-24 22:06:59 +08:00
Nexmoe
e8cd89d7e4 chore: update release workflow to download yt-dlp binaries for Windows and Linux 2025-10-24 22:03:48 +08:00
Nexmoe
4caf8efa72 chore: release v0.1.7 2025-10-24 20:47:35 +08:00
Nexmoe
ed707df837 refactor: move download engine to lib directory for better organization 2025-10-24 20:47:23 +08:00
Nexmoe
0821aea72b refactor: streamline download path handling and remove unused output path logic 2025-10-24 19:51:25 +08:00
Nexmoe
4dfac6bb34 chore: add CONTRIBUTING.md for project guidelines and enhance README with author follow section 2025-10-24 19:22:37 +08:00
Nexmoe
8ad41eebe2 chore: update README with enhanced features, add screenshots, and implement auto-updater functionality 2025-10-23 23:22:32 +08:00
Nexmoe
7c64d255b8 chore: release v0.1.6 2025-10-23 22:28:24 +08:00
Nexmoe
bad839934d chore: remove unnecessary directory creation step in release workflow 2025-10-23 22:28:15 +08:00
Nexmoe
be06106f66 chore: release v0.1.5 2025-10-23 22:25:47 +08:00
Nexmoe
b6dc09a7e3 chore: enhance release workflow to download yt-dlp binaries for cross-platform support 2025-10-23 22:25:37 +08:00
Nexmoe
edbc572bf1 chore: release v0.1.4 2025-10-23 22:07:12 +08:00
Nexmoe
370a165572 chore: update release workflow to target only Windows environment 2025-10-23 22:07:03 +08:00
Nexmoe
6b0f9d0798 chore: release v0.1.3 2025-10-23 22:03:08 +08:00
Nexmoe
bc78f8a3ef chore: refactor release workflow to simplify build steps and update artifact handling 2025-10-23 22:02:57 +08:00
Nexmoe
297916c35b chore: update CI workflow to trigger pull requests only on the main branch 2025-10-23 21:53:46 +08:00
Nexmoe
e2b0aacf05 chore: release v0.1.2 2025-10-23 21:52:35 +08:00
Nexmoe
d9204049fe chore: specify shell for build step in release workflow 2025-10-23 21:52:25 +08:00
102 changed files with 17325 additions and 2704 deletions

133
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Build
on:
workflow_call:
inputs:
upload_artifacts:
required: false
type: boolean
default: false
description: 'Whether to upload build artifacts'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: windows
os: windows-latest
build_script: pnpm run build:win
ytdlp_asset: yt-dlp.exe
ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
ffmpeg_output: ffmpeg.exe
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
ffmpeg_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
ytdlp_asset: yt-dlp
ytdlp_output: yt-dlp_linux
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Download ffmpeg binary (Windows)
if: matrix.platform == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$ffmpegUrl = '${{ matrix.ffmpeg_url }}'
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
Copy-Item -Path $source -Destination $destination -Force
- name: Download ffmpeg binary (macOS)
if: matrix.platform == 'macos'
shell: bash
env:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
run: |
set -euo pipefail
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
if [[ ! -f "$arm_bin" ]]; then
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
exit 1
fi
if [[ ! -f "$x86_bin" ]]; then
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
exit 1
fi
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
chmod +x "resources/$FFMPEG_OUTPUT"
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
- name: Download ffmpeg binary (Linux)
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
chmod +x "resources/${{ matrix.ffmpeg_output }}"
- name: Download yt-dlp binary
shell: bash
run: |
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
chmod +x "resources/${{ matrix.ytdlp_output }}"
fi
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Build application
run: ${{ matrix.build_script }}
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/
retention-days: 1

View File

@@ -1,37 +1,9 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- name: Lint and format check
run: pnpm run check
- name: Build check
run: pnpm run build
build:
uses: ./.github/workflows/build.yml

View File

@@ -7,85 +7,38 @@ on:
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- name: Lint and format check
run: pnpm run check
- name: Build Electron app
run: |
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
pnpm run build:linux
elif [ "${{ matrix.os }}" == "macos-latest" ]; then
pnpm run build:mac
elif [ "${{ matrix.os }}" == "windows-latest" ]; then
pnpm run build:win
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
release:
needs: build
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Download all artifacts
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: dist-*
merge-multiple: true
path: dist/
- name: Create Release
uses: softprops/action-gh-release@v2
- name: Release
uses: softprops/action-gh-release@v1
with:
draft: false
prerelease: false
generate_release_notes: true
files: |
dist-ubuntu-latest/*.AppImage
dist-ubuntu-latest/*.snap
dist-ubuntu-latest/*.deb
dist-ubuntu-latest/*.rpm
dist-ubuntu-latest/*.tar.gz
dist-ubuntu-latest/*.yml
dist-ubuntu-latest/*.blockmap
dist-macos-latest/*.dmg
dist-macos-latest/*.zip
dist-macos-latest/*.yml
dist-macos-latest/*.blockmap
dist-windows-latest/*.exe
dist-windows-latest/*.zip
dist-windows-latest/*.yml
dist-windows-latest/*.blockmap
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

29
.github/workflows/translator.yaml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'translator'
on:
issues:
types: [opened, edited]
issue_comment:
types: [created, edited]
discussion:
types: [created, edited]
discussion_comment:
types: [created, edited]
pull_request_target:
types: [opened, edited]
pull_request_review_comment:
types: [created, edited]
jobs:
translate:
permissions:
issues: write
discussions: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
IS_MODIFY_TITLE: true

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
node_modules
dist
out
.conductor/
.DS_Store
.eslintcache
*.log*

78
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,78 @@
# Contributing to VidBee
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
## Getting Ready
- Use Node.js 18+ and pnpm 8+.
- Install dependencies with `pnpm install`.
- Run `pnpm dev` to test changes locally.
## Tech Stack
- Runtime: Electron 38, electron-vite, electron-builder.
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
## Local Development
- Use `pnpm install` to pull dependencies after cloning.
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
- Preview the production build locally with `pnpm start`.
## Useful Scripts
| Command | Purpose |
| --- | --- |
| `pnpm run typecheck` | Type-check the main and renderer projects. |
| `pnpm build` | Run type checks and produce production bundles. |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
| `pnpm run check` | Format and lint the codebase with Biome. |
## Project Structure
```text
src/
|-- main/ # Electron main process, IPC services, configuration
|-- preload/ # Context bridge and preload helpers
`-- renderer/
|-- src/
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
| |-- components/ # UI components, download views, shared controls
| |-- data/ # Static datasets such as popularSites.ts
| |-- hooks/ # Custom hooks and global atoms
| |-- lib/ # Utilities shared across the renderer
| `-- assets/ # Global styles and icons
`-- index.html
```
## Internationalization
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
- Only update strings in `src/renderer/src/locales/en.json`; maintainers handle the other locales.
- Keep copy edits focused and avoid removing translation keys without discussion.
## Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
- Logs are recorded with `electron-log` to simplify troubleshooting.
## Packaging
- Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
## Working on Changes
- Keep each pull request focused on a single problem or feature.
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
- Write comments and console messages in English only.
- When updating copy in the app, adjust strings in `src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
## Opening Issues
- Search existing issues to avoid duplicates.
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
## Submitting Pull Requests
- Explain the motivation and impact of the change in the description.
- Mention any user facing updates or migrations.
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
We appreciate every contribution that keeps VidBee simple and reliable.

176
README.md
View File

@@ -1,97 +1,111 @@
# VidBee
<div align="center">
<img src="build/icon.png" alt="VidBee icon" width="120" />
<h3>A minimal Electron downloader for video and audio</h3>
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
<a href="https://github.com/nexmoe/VidBee">
<img src="build/icon.png" alt="Logo" width="80" height="80">
</a>
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&style=flat-square&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<br />
<br />
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
<br />
<br />
</p>
</div>
## Features
- Download single videos, audio-only tracks, or entire playlists through a unified flow.
- Queue multiple jobs with progress tracking, pause, resume, retry, and download history.
- Detect platform-friendly formats automatically and store files in custom locations.
- Quick actions for popular sites plus manual URL entry for anything yt-dlp supports.
- Theme toggle (system, light, dark) and localized interface in English and Simplified Chinese.
- Desktop-native touches such as tray integration, update checks, and persistent settings.
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs.
## Tech Stack
- **Runtime:** Electron 38, electron-vite, electron-builder.
- **Frontend:** React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
- **Tooling:** TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
## 👋🏻 Getting Started
## Getting Started
### Prerequisites
- Node.js 18 or newer
- pnpm 8 or newer
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
### Install dependencies
```bash
pnpm install
```
Feel free to try it using the following methods:
### Run the app in development
```bash
pnpm dev
```
| Operating System | Source |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
The Electron app and Vite dev server launch together with hot module replacement.
### 🍎 macOS Installation Notes
## Useful Scripts
| Command | Description |
| --- | --- |
| `pnpm dev` | Run the Electron and Vite development environment. |
| `pnpm start` | Preview the production build locally. |
| `pnpm run typecheck` | Type-check the main and renderer projects. |
| `pnpm build` | Run type checks and produce production bundles. |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
| `pnpm build:unpack` | Produce unpacked output directories. |
| `pnpm run check` | Format and lint the codebase with Biome. |
## Project Structure
```
src/
├─ main/ # Electron main process, IPC services, configuration
├─ preload/ # Context bridge and preload helpers
└─ renderer/
├─ src/
│ ├─ pages/ # Application routes (Home, Settings, Playlist, etc.)
│ ├─ components/ # UI components, download views, shared controls
│ ├─ data/ # Static datasets such as popularSites.ts
│ ├─ hooks/ # Custom hooks and global atoms
│ ├─ lib/ # Utilities shared across the renderer
│ └─ assets/ # Global styles and icons
└─ index.html
```
## Internationalization
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
## Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
- Logs are recorded with `electron-log` to simplify troubleshooting.
## Packaging
Run one of the following commands after a successful build:
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
```bash
pnpm build:win
pnpm build:mac
pnpm build:linux
xattr -rd com.apple.quarantine /Applications/VidBee.app/
```
Artifacts are generated under `dist/`. Use `pnpm build:unpack` to create unpacked directories for manual inspection.
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
## Contributing
Issues and pull requests are welcome. Keep changes focused, document user facing updates, and run `pnpm run check` before opening a PR.
> [!IMPORTANT]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
## License
This project is distributed under the MIT License. See `LICENSE` for details.
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
</picture>
</a>
## Thanks
- [Electron](https://www.electronjs.org/)
- [React](https://react.dev/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
## ✨ Features
### 🌍 Global Video Download Support
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
![VidBee Main Interface](screenshots/main-interface.png)
### 🎨 Best-in-class UI Experience
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
![VidBee Download Queue](screenshots/download-queue.png)
## 🌐 Supported Sites
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
| Video Platforms | Audio & Other Platforms |
| :--------------- | :---------------------- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
## 🤝 Contributing
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
## 📄 License
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
## 🙏 Thanks
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
- [React](https://react.dev/) - The UI library
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components

Binary file not shown.

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/main/lib/database/schema.ts',
out: './resources/drizzle',
dialect: 'sqlite'
})

View File

@@ -2,6 +2,10 @@ appId: com.vidbee
productName: VidBee
directories:
buildResources: build
protocols:
- name: VidBee
schemes:
- vidbee
files:
- '!**/.vscode/*'
- '!src/*'
@@ -19,16 +23,24 @@ nsis:
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
identity: null
entitlementsInherit: build/entitlements.mac.plist
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
target:
- target: zip
arch:
- arm64
- x64
- target: dmg
arch:
- arm64
- x64
linux:
target:
- AppImage
- snap
- deb
maintainer: yourname@example.com
maintainer: nexmoex@gmail.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

BIN
icon.icns

Binary file not shown.

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "0.1.1",
"version": "1.0.2",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -11,14 +11,16 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp"
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "git checkout main && git pull && pnpm run check && bumpp",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
@@ -26,8 +28,10 @@
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@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-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -40,13 +44,16 @@
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@tailwindcss/vite": "^4.1.13",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.7",
"electron-ipc-decorator": "^0.2.0",
"electron-log": "^5.4.3",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"flag-icons": "^7.5.0",
"i18next": "^25.5.3",
"jotai": "^2.15.0",
"lucide-react": "^0.544.0",
@@ -54,9 +61,11 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^16.0.0",
"react-router": "^7.9.4",
"rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"yt-dlp-wrap-plus": "^2.3.20",
"zod": "^4.1.11"
},
@@ -69,6 +78,7 @@
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"bumpp": "^10.3.1",
"drizzle-kit": "^0.31.7",
"electron": "^38.1.2",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",

652
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,11 @@
yt-dlp.exe
yt-dlp_macos
yt-dlp_linux
ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
# But keep the README
!README.md
!.gitignore

View File

@@ -48,8 +48,24 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
```
## ffmpeg Binaries
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
### Required Files
1. **Windows**: `ffmpeg.exe`
2. **macOS**: `ffmpeg_macos`
3. **Linux**: `ffmpeg_linux`
### How to Download
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
### Note
- If you don't place binaries here, the app will attempt to download them at runtime
- The app will automatically use the bundled version if available
- File sizes: ~10-15 MB per binary
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary

View File

@@ -0,0 +1,66 @@
CREATE TABLE `download_history` (
`id` text PRIMARY KEY NOT NULL,
`url` text NOT NULL,
`title` text NOT NULL,
`thumbnail` text,
`type` text NOT NULL,
`status` text NOT NULL,
`download_path` text,
`saved_file_name` text,
`file_size` integer,
`duration` integer,
`downloaded_at` integer NOT NULL,
`completed_at` integer,
`sort_key` integer NOT NULL,
`error` text,
`description` text,
`channel` text,
`uploader` text,
`view_count` integer,
`tags` text,
`origin` text,
`subscription_id` text,
`selected_format` text,
`playlist_id` text,
`playlist_title` text,
`playlist_index` integer,
`playlist_size` integer
);
--> statement-breakpoint
CREATE TABLE `subscription_items` (
`subscription_id` text NOT NULL,
`item_id` text NOT NULL,
`title` text NOT NULL,
`url` text NOT NULL,
`published_at` integer NOT NULL,
`thumbnail` text,
`added` integer NOT NULL,
`download_id` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
PRIMARY KEY(`subscription_id`, `item_id`)
);
--> statement-breakpoint
CREATE INDEX `subscription_items_subscription_idx` ON `subscription_items` (`subscription_id`);--> statement-breakpoint
CREATE TABLE `subscriptions` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`source_url` text NOT NULL,
`feed_url` text NOT NULL,
`platform` text NOT NULL,
`keywords` text NOT NULL,
`tags` text NOT NULL,
`only_latest` integer NOT NULL,
`enabled` integer NOT NULL,
`cover_url` text,
`latest_video_title` text,
`latest_video_published_at` integer,
`last_checked_at` integer,
`last_success_at` integer,
`status` text NOT NULL,
`last_error` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`download_directory` text,
`naming_template` text
);

View File

@@ -0,0 +1,451 @@
{
"version": "6",
"dialect": "sqlite",
"id": "91501763-7554-435d-91d8-382c52ee65e4",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail": {
"name": "thumbnail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_path": {
"name": "download_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"saved_file_name": {
"name": "saved_file_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_key": {
"name": "sort_key",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel": {
"name": "channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uploader": {
"name": "uploader",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"selected_format": {
"name": "selected_format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_id": {
"name": "playlist_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_title": {
"name": "playlist_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_index": {
"name": "playlist_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"playlist_size": {
"name": "playlist_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscription_items": {
"name": "subscription_items",
"columns": {
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail": {
"name": "thumbnail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added": {
"name": "added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_id": {
"name": "download_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"subscription_items_subscription_idx": {
"name": "subscription_items_subscription_idx",
"columns": ["subscription_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"subscription_items_pk": {
"columns": ["subscription_id", "item_id"],
"name": "subscription_items_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feed_url": {
"name": "feed_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keywords": {
"name": "keywords",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"only_latest": {
"name": "only_latest",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_url": {
"name": "cover_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_video_title": {
"name": "latest_video_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_video_published_at": {
"name": "latest_video_published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_checked_at": {
"name": "last_checked_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_success_at": {
"name": "last_success_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"download_directory": {
"name": "download_directory",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"naming_template": {
"name": "naming_template",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1763176841336,
"tag": "0000_swift_aaron_stack",
"breakpoints": true
}
]
}

BIN
resources/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

72
scripts/check-ytdlp.js Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env node
const fs = require('node:fs')
const path = require('node:path')
// Get platform from command line arguments
const platform = process.argv[2]
if (!platform) {
console.error('❌ Error: Platform argument is required!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const supportedPlatforms = ['win', 'mac', 'linux']
if (!supportedPlatforms.includes(platform)) {
console.error('❌ Error: Invalid platform specified!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const binaries = [
{
label: 'yt-dlp',
filenameMap: {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
},
help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
}
},
{
label: 'ffmpeg',
filenameMap: {
win: 'ffmpeg.exe',
mac: 'ffmpeg_macos',
linux: 'ffmpeg_linux'
},
help: {
win: 'https://ffmpeg.org/download.html',
linux: 'https://ffmpeg.org/download.html',
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
}
}
]
let hasMissingBinary = false
for (const binary of binaries) {
const filename = binary.filenameMap[platform]
const binaryPath = path.join(__dirname, '..', 'resources', filename)
if (!fs.existsSync(binaryPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
const help =
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
if (help) {
console.error(`See ${help}`)
}
hasMissingBinary = true
} else {
console.log(`${filename} found in resources/ directory`)
}
}
if (hasMissingBinary) {
process.exit(1)
}

View File

@@ -0,0 +1,28 @@
/**
* 设置控制台编码为 UTF-8解决中文乱码问题
* 这个脚本在 Windows 上设置控制台代码页为 UTF-8
*/
const { exec } = require('node:child_process')
const os = require('node:os')
if (os.platform() === 'win32') {
console.log('Setting console encoding to UTF-8...')
// 设置控制台代码页为 UTF-8 (65001)
exec('chcp 65001', (error, stdout, stderr) => {
if (error) {
console.warn('Failed to set console code page:', error)
return
}
if (stderr) {
console.warn('Console code page setting warning:', stderr)
}
console.log('Console encoding set to UTF-8')
console.log('Output:', stdout)
})
} else {
console.log('Not on Windows, no console encoding change needed')
}

343
scripts/setup-dev-binaries.js Executable file
View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* Development environment setup script
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
*/
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { execSync } = require('node:child_process')
const https = require('node:https')
const http = require('node:http')
// Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
// Platform configuration
const PLATFORM_CONFIG = {
win32: {
ytdlp: {
asset: 'yt-dlp.exe',
output: 'yt-dlp.exe'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
output: 'ffmpeg.exe',
extract: 'unzip'
}
},
darwin: {
ytdlp: {
asset: 'yt-dlp_macos',
output: 'yt-dlp_macos'
},
ffmpeg: {
// For development, download only the architecture matching current system
arm64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
}
}
},
linux: {
ytdlp: {
asset: 'yt-dlp',
output: 'yt-dlp_linux'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
extract: 'tar'
}
}
}
// Utility functions
function log(message, type = 'info') {
const icons = {
info: '📦',
success: '✅',
error: '❌',
warn: '⚠️',
download: '⬇️'
}
console.log(`${icons[type] || ''} ${message}`)
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
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) => {
file.close()
fs.unlinkSync(dest)
reject(err)
})
})
}
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
if (platform === 'win32') {
// Use PowerShell Expand-Archive on Windows
try {
const zipAbsPath = path.resolve(zipPath)
const extractAbsDir = path.resolve(extractDir)
execSync(
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
{ stdio: 'inherit' }
)
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
} else {
// Use unzip command on macOS/Linux
try {
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
}
}
function extractTarXz(tarPath, extractDir) {
ensureDir(extractDir)
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
}
function setExecutable(filePath) {
if (os.platform() !== 'win32') {
fs.chmodSync(filePath, 0o755)
}
}
function fileExists(filePath) {
return fs.existsSync(filePath)
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ${asset}...`, 'download')
const url = `${YTDLP_BASE_URL}/${asset}`
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFile(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
throw error
}
}
async function downloadFfmpegWindows(config) {
const { url, innerPath, output } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Windows...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegMac(config) {
const arch = os.arch()
const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64']
if (!ffmpegConfig) {
throw new Error(`Unsupported architecture: ${arch}`)
}
const { url, innerPath, output } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegLinux(config) {
const { url, innerPath, output } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Linux...`, 'download')
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempTar)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
const config = PLATFORM_CONFIG[platform]
if (!config) {
log(`Unsupported platform: ${platform}`, 'error')
process.exit(1)
}
log(`Setting up development binaries for ${platform}...`, 'info')
ensureDir(RESOURCES_DIR)
try {
// Download yt-dlp
await downloadYtDlp(config)
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)
} else if (platform === 'darwin') {
await downloadFfmpegMac(config)
} else if (platform === 'linux') {
await downloadFfmpegLinux(config)
}
log('Development environment setup completed!', 'success')
} catch (error) {
log(`Setup failed: ${error.message}`, 'error')
process.exit(1)
}
}
// Run setup
if (require.main === module) {
setup()
}
module.exports = { setup }

View File

@@ -0,0 +1,44 @@
import log from 'electron-log/main'
/**
* Configure electron-log
* Set log format, file path, transport methods, etc.
*/
export function configureLogger() {
// Configure console output format - support colors and scope, time in gray
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
// Enable console colors
log.transports.console.useStyles = true
// Configure file output format - include scope information
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
// Set log levels
// Development: show all logs
// Production: show info level and above only
const isDev = process.env.NODE_ENV === 'development'
log.transports.console.level = isDev ? 'silly' : 'info'
log.transports.file.level = isDev ? 'silly' : 'info'
// Enable IPC transport in development environment to show renderer process logs in main process console
if (isDev) {
log.transports.ipc.level = 'silly'
} else {
log.transports.ipc.level = false
}
// Set maximum log file size (10MB)
log.transports.file.maxSize = 10 * 1024 * 1024
// Enable error catching - catch unhandled errors and rejected promises
log.errorHandler.startCatching({
showDialog: false, // Don't show error dialog, only log to file
onError: (options) => {
log.error('Unhandled error caught by electron-log:', options.error)
log.error('App versions:', options.versions)
}
})
log.info('Log file location:', log.transports.file.getFile().path)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
import path from 'node:path'
import type { AppSettings, DownloadOptions } from '../../shared/types'
import { resolvePathWithHome } from '../utils/path-helpers'
export const sanitizeFilenameTemplate = (template: string): string => {
const trimmed = template.trim()
const sanitized = trimmed.replace(/[/\\]+/g, '-')
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
}
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
const format = options.format
const audioFormat = options.audioFormat
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
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'
}
return `bestvideo+${audioFormat}`
}
if (audioFormat === 'none') {
return `${format}+none`
}
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
return `${format}+${audio}`
}
export const resolveAudioFormatSelector = (options: DownloadOptions): string => {
const format = options.format
if (!format) {
return 'bestaudio'
}
if (format.includes('/') || format.includes('+') || format.includes('[')) {
return format
}
return format
}
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
const formatSelector = resolveVideoFormatSelector(options)
args.push('-f', formatSelector)
// 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
if (options.startTime || options.endTime) {
const start = options.startTime || '0'
const end = options.endTime || ''
args.push('--download-sections', `*${start}-${end || ''}`)
}
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
}
// Output path with proper encoding handling
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
const filenameTemplate = sanitizeFilenameTemplate(
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
)
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
args.push('-o', outputTemplate)
// Add options for better filename handling
args.push('--no-part')
args.push('--no-playlist-reverse')
if (process.platform === 'win32') {
args.push('--windows-filenames')
}
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
args.push(options.url)
return args
}

View File

@@ -0,0 +1,208 @@
import type {
AppSettings,
DownloadOptions,
OneClickQualityPreset,
VideoFormat
} from '../../shared/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 selectVideoFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
if (heightDiff !== 0) return heightDiff
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
if (fpsDiff !== 0) return fpsDiff
return (b.tbr ?? 0) - (a.tbr ?? 0)
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const heightLimit = qualityPresetToVideoHeight[preset]
if (!heightLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const height = format.height ?? 0
return height > 0 && height <= heightLimit
})
return withinLimit ?? sorted[0]
}
const selectAudioFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
if (bitrateDiff !== 0) return bitrateDiff
const sizeA = a.filesize ?? a.filesize_approx ?? 0
const sizeB = b.filesize ?? b.filesize_approx ?? 0
if (sizeB !== sizeA) return sizeB - sizeA
return 0
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const abrLimit = qualityPresetToAudioAbr[preset]
if (!abrLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const bitrate = format.tbr ?? 0
return bitrate > 0 && bitrate <= abrLimit
})
return withinLimit ?? sorted[0]
}
const findFormatBySelector = (
formats: VideoFormat[],
selector?: string
): VideoFormat | undefined => {
if (!selector) {
return undefined
}
const candidateIds = selector
.split('/')
.map((option) => option.split('+')[0].trim())
.filter((option) => option.length > 0)
for (const candidateId of candidateIds) {
const match = formats.find((format) => format.format_id === candidateId)
if (match) {
return match
}
}
return undefined
}
export const findFormatByIdCandidates = (
formats: VideoFormat[],
rawFormatId: string | undefined
): VideoFormat | undefined => {
if (!rawFormatId) {
return undefined
}
const parts = rawFormatId
.split('+')
.map((part) => part.trim())
.filter((part) => part.length > 0)
for (const part of parts) {
const match = formats.find((format) => format.format_id === part)
if (match) {
return match
}
}
return undefined
}
export const parseSizeToBytes = (value?: string): number | undefined => {
if (!value) {
return undefined
}
const cleaned = value.trim().replace(/^~\s*/, '')
if (!cleaned) {
return undefined
}
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
if (!match) {
return undefined
}
const amount = Number(match[1].replace(/,/g, ''))
if (Number.isNaN(amount)) {
return undefined
}
const unit = match[2].toUpperCase()
const multipliers: Record<string, number> = {
B: 1,
KB: 1_000,
KIB: 1_024,
MB: 1_000_000,
MIB: 1_048_576,
GB: 1_000_000_000,
GIB: 1_073_741_824,
TB: 1_000_000_000_000,
TIB: 1_099_511_627_776
}
const multiplier = multipliers[unit]
if (!multiplier) {
return undefined
}
return Math.round(amount * multiplier)
}
export const resolveSelectedFormat = (
formats: VideoFormat[],
options: DownloadOptions,
settings: AppSettings
): VideoFormat | undefined => {
const directMatch = findFormatBySelector(formats, options.format)
if (directMatch) {
return directMatch
}
const preset = settings.oneClickQuality ?? 'best'
if (options.type === 'video') {
const videoFormats = formats.filter(
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
)
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&
format.acodec !== 'none' &&
(!format.video_ext || format.video_ext === 'none')
)
return selectAudioFormatForPreset(audioFormats, preset)
}
return undefined
}

View File

@@ -1,30 +1,60 @@
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import log from 'electron-log'
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { downloadEngine } from './download-engine'
import { configureLogger } from './config/logger-config'
import { services } from './ipc'
import { downloadEngine } from './lib/download-engine'
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 { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
import { applyAutoLaunchSetting } from './utils/auto-launch'
import { applyDockVisibility } from './utils/dock'
// Initialize electron-log for main process
log.initialize()
// Configure logger settings
configureLogger()
const RENDERER_DIST_PATH = join(__dirname, '../renderer')
protocol.registerSchemesAsPrivileged([
{
scheme: APP_PROTOCOL,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true
}
}
])
let mainWindow: BrowserWindow | null = null
let isQuitting = false
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
})
export function createWindow(): void {
// Create the browser window
mainWindow = new BrowserWindow({
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200,
height: 800,
show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS
autoHideMenuBar: true,
icon: appIcon, // Set application icon
frame: false,
vibrancy: 'fullscreen-ui', // on MacOS
backgroundMaterial: 'acrylic', // on Windows 11
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -32,7 +62,20 @@ export function createWindow(): void {
nodeIntegration: false,
webSecurity: false // Allow drag regions to work
}
})
}
if (isMac) {
windowOptions.titleBarStyle = 'hidden'
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
windowOptions.vibrancy = 'fullscreen-ui'
}
if (isWindows) {
windowOptions.backgroundMaterial = 'acrylic'
}
// Create the browser window
mainWindow = new BrowserWindow(windowOptions)
mainWindow.on('close', (event) => {
const closeToTray = settingsManager.get('closeToTray')
@@ -56,9 +99,13 @@ export function createWindow(): void {
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
}
mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
})
// Setup download engine event forwarding to renderer
setupDownloadEvents()
}
@@ -85,6 +132,131 @@ function setupDownloadEvents(): void {
})
}
function sanitizeRequestPath(requestUrl: URL): string {
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
const trimmedLeading = rawPath.replace(/^\/+/, '')
const cleaned = trimmedLeading.replace(/\/+$/, '')
return cleaned || 'index.html'
}
function isWithinBase(targetPath: string, basePath: string): boolean {
const relativePath = relative(basePath, targetPath)
return !relativePath.startsWith('..') && !isAbsolute(relativePath)
}
function resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {
const sanitizedPath = sanitizeRequestPath(requestUrl)
const [rootSegment, ...restSegments] = sanitizedPath.split('/')
const rendererPath = restSegments.join('/') || 'index.html'
if (rootSegment === 'renderer') {
const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)
if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {
return rendererTarget
}
}
const userDataTarget = resolve(userDataPath, sanitizedPath)
if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {
return userDataTarget
}
const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)
if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {
return rendererFallback
}
return null
}
function registerVidbeeProtocol(): void {
try {
const userDataPath = app.getPath('userData')
protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {
const requestUrl = new URL(request.url)
const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)
if (!filePath) {
log.error(`File not found for ${request.url}`)
callback({ error: -6 })
return
}
callback(filePath)
})
} catch (error) {
log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)
}
}
function initAutoUpdater(): void {
try {
log.info('Initializing auto-updater...')
log.transports.file.level = 'info'
autoUpdater.logger = log
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
mainWindow?.webContents.send('update:available', info)
// If auto-update is enabled, the update will be downloaded automatically
// because autoDownload is set to true
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, update will be downloaded automatically')
}
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
log.info('Auto-updater initialized successfully')
// Check for updates immediately if auto-update is enabled
const autoUpdateEnabled = settingsManager.get('autoUpdate')
if (autoUpdateEnabled) {
log.info('Auto-update is enabled, checking for updates immediately...')
// Use checkForUpdates instead of checkForUpdatesAndNotify
// because we have our own notification system and want to ensure immediate download
void autoUpdater.checkForUpdates()
} else {
log.info('Auto-update is disabled, skipping automatic update check')
}
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -92,6 +264,13 @@ app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.vidbee')
registerVidbeeProtocol()
const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)
if (!registered) {
log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)
}
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
app.on('browser-window-created', (_, window) => {
@@ -99,98 +278,38 @@ app.whenReady().then(async () => {
})
// IPC services are automatically registered by electron-ipc-decorator when imported
console.log('IPC services available:', Object.keys(services))
log.info('IPC services available:', Object.keys(services))
// Initialize ffmpeg
try {
log.info('Initializing ffmpeg...')
await ffmpegManager.initialize()
log.info('ffmpeg initialized successfully')
} catch (error) {
log.error('Failed to initialize ffmpeg:', error)
}
// Initialize yt-dlp
try {
console.log('Initializing yt-dlp...')
log.info('Initializing yt-dlp...')
await ytdlpManager.initialize()
console.log('yt-dlp initialized successfully')
log.info('yt-dlp initialized successfully')
} catch (error) {
console.error('Failed to initialize yt-dlp:', error)
log.error('Failed to initialize yt-dlp:', error)
}
// Initialize auto-updater (only in production)
if (process.env.NODE_ENV === 'production') {
try {
console.log('Initializing auto-updater...')
// Configure logging
log.transports.file.level = 'info'
autoUpdater.logger = log
// Configure auto-updater for production
autoUpdater.autoDownload = true // Auto download updates
autoUpdater.autoInstallOnAppQuit = true // Auto install on quit
// Set up event listeners
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:available', info)
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
// Send error to renderer
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
// Send progress to renderer
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:downloaded', info)
// Show system notification
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
// Check for updates on startup if auto-update is enabled
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
// Use checkForUpdatesAndNotify for automatic notifications
await autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
} else {
console.log('Skipping auto-updater initialization in development mode')
}
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
createWindow()
initAutoUpdater()
// Create system tray
createTray()
subscriptionScheduler.start()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.

View File

@@ -4,6 +4,7 @@ import { DownloadService } from './services/download-service'
import { FileSystemService } from './services/file-system-service'
import { HistoryService } from './services/history-service'
import { SettingsService } from './services/settings-service'
import { SubscriptionService } from './services/subscription-service'
import { ThumbnailService } from './services/thumbnail-service'
import { UpdateService } from './services/update-service'
import { WindowService } from './services/window-service'
@@ -15,6 +16,7 @@ export const services = createServices([
FileSystemService,
HistoryService,
SettingsService,
SubscriptionService,
ThumbnailService,
UpdateService,
WindowService

View File

@@ -32,6 +32,26 @@ class AppService extends IpcService {
return dialog.showMessageBox(options)
}
@IpcMethod()
async getSiteIcon(_context: IpcContext, domain: string): Promise<string | null> {
try {
const iconUrl = `https://unavatar.io/${domain}`
const response = await fetch(iconUrl)
if (!response.ok) {
return null
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const contentType = response.headers.get('content-type') || 'image/png'
const base64 = buffer.toString('base64')
return `data:${contentType};base64,${base64}`
} catch (error) {
console.error('Failed to fetch site icon:', error)
return null
}
}
}
export { AppService }

View File

@@ -3,10 +3,11 @@ import type {
DownloadItem,
DownloadOptions,
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoInfo
} from '../../../shared/types'
import { downloadEngine } from '../../download-engine'
import { downloadEngine } from '../../lib/download-engine'
class DownloadService extends IpcService {
static readonly groupName = 'download'
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
async startPlaylistDownload(
_context: IpcContext,
options: PlaylistDownloadOptions
): Promise<string[]> {
): Promise<PlaylistDownloadResult> {
return downloadEngine.startPlaylistDownload(options)
}
}

View File

@@ -1,10 +1,14 @@
import type { Dirent } from 'node:fs'
import { execFile, execSync } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { dialog, shell } from 'electron'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { clipboard, dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
const execFileAsync = promisify(execFile)
class FileSystemService extends IpcService {
static readonly groupName = 'fs'
@@ -36,7 +40,20 @@ class FileSystemService extends IpcService {
@IpcMethod()
getDefaultDownloadPath(_context: IpcContext): string {
return `${os.homedir()}/Downloads`
const fallbackPath = path.join(os.homedir(), 'Downloads')
if (process.platform === 'linux' || process.platform === 'freebsd') {
try {
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
if (xdgPath) {
return xdgPath
}
} catch (error) {
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
}
}
return fallbackPath
}
@IpcMethod()
@@ -56,12 +73,6 @@ class FileSystemService extends IpcService {
}
if (stats?.isDirectory()) {
const candidate = await this.findLikelyFile(normalizedPath, normalizedPath)
if (candidate) {
shell.showItemInFolder(candidate)
return true
}
const result = await shell.openPath(normalizedPath)
if (result) {
console.error('Failed to open directory:', result)
@@ -70,94 +81,66 @@ class FileSystemService extends IpcService {
return true
}
const fallbackDirectory = path.dirname(normalizedPath)
const fallbackCandidate = await this.findLikelyFile(fallbackDirectory, normalizedPath)
if (fallbackCandidate) {
shell.showItemInFolder(fallbackCandidate)
// If the exact path doesn't exist, try to open the parent directory
const parentDirectory = path.dirname(normalizedPath)
const parentStats = await fs.stat(parentDirectory).catch(() => null)
if (parentStats?.isDirectory()) {
const result = await shell.openPath(parentDirectory)
if (result) {
console.error('Failed to open parent directory:', result)
return false
}
return true
}
const result = await shell.openPath(fallbackDirectory)
if (!result) {
return true
}
console.error('Failed to open directory:', result)
console.error('File or directory does not exist:', normalizedPath)
return false
} catch (error) {
try {
const directory = path.dirname(path.normalize(this.sanitizePath(filePath)))
const dirStats = await fs.stat(directory)
if (dirStats.isDirectory()) {
const fallbackCandidate = await this.findLikelyFile(directory, directory)
if (fallbackCandidate) {
shell.showItemInFolder(fallbackCandidate)
return true
}
const result = await shell.openPath(directory)
if (result) {
console.error('Failed to open directory:', result)
return false
}
return true
}
} catch (dirError) {
console.error('Failed to open parent directory:', dirError)
}
console.error('Failed to open file location:', error)
return false
}
}
return false
@IpcMethod()
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath)
if (!stats.isFile()) {
return false
}
const resolvedPath = path.resolve(normalizedPath)
await this.copyFileToClipboardByPlatform(resolvedPath)
return true
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
return false
}
}
private sanitizePath(target: string): string {
return target.trim().replace(/^['"]|['"]$/g, '')
}
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
try {
const dirStats = await fs.stat(directory)
if (!dirStats.isDirectory()) {
return null
}
const entries = await fs.readdir(directory, { withFileTypes: true })
const files = entries.filter((entry: Dirent) => entry.isFile())
if (files.length === 0) {
return null
}
const expectedBase = path.basename(expectedPath).toLowerCase()
const expectedName = path.parse(expectedPath).name.toLowerCase()
const exactMatch = files.find((entry) => entry.name.toLowerCase() === expectedBase)
if (exactMatch) {
return path.join(directory, exactMatch.name)
}
if (expectedName) {
const partialMatch = files.find((entry) => entry.name.toLowerCase().includes(expectedName))
if (partialMatch) {
return path.join(directory, partialMatch.name)
}
}
let latestMatch: { filePath: string; mtimeMs: number } | null = null
for (const entry of files) {
const candidatePath = path.join(directory, entry.name)
try {
const candidateStats = await fs.stat(candidatePath)
if (!latestMatch || candidateStats.mtimeMs > latestMatch.mtimeMs) {
latestMatch = { filePath: candidatePath, mtimeMs: candidateStats.mtimeMs }
}
} catch (statError) {
console.error('Failed to stat candidate file:', statError)
}
}
return latestMatch?.filePath ?? null
} catch (error) {
console.error('Failed to search for matching file:', error)
return null
private async copyFileToClipboardByPlatform(resolvedPath: string): Promise<void> {
switch (process.platform) {
case 'win32':
await this.copyFileToClipboardWindows(resolvedPath)
return
case 'darwin':
await this.copyFileToClipboardMac(resolvedPath)
return
default:
await this.copyFileToClipboardLinux(resolvedPath)
}
}
@@ -171,6 +154,151 @@ class FileSystemService extends IpcService {
return false
}
}
private async copyFileToClipboardWindows(resolvedPath: string): Promise<void> {
const escaped = resolvedPath.replace(/'/g, "''")
try {
await execFileAsync('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-Command',
`Set-Clipboard -Path '${escaped}'`
])
return
} catch (error) {
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
}
const winPath = resolvedPath.replace(/\//g, '\\')
const fileList = `${winPath}\u0000\u0000`
const encodedList = Buffer.from(fileList, 'ucs2')
const dropFilesStructSize = 20
const buffer = Buffer.alloc(dropFilesStructSize + encodedList.length)
buffer.writeUInt32LE(dropFilesStructSize, 0)
buffer.writeInt32LE(0, 4)
buffer.writeInt32LE(0, 8)
buffer.writeUInt32LE(0, 12)
buffer.writeUInt32LE(1, 16)
encodedList.copy(buffer, dropFilesStructSize)
clipboard.writeBuffer('CF_HDROP', buffer)
clipboard.writeBuffer('Preferred DropEffect', Buffer.from([1, 0, 0, 0]))
clipboard.writeBuffer('FileNameW', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ucs2'))
clipboard.writeBuffer('FileName', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ascii'))
}
private async copyFileToClipboardMac(resolvedPath: string): Promise<void> {
const escaped = resolvedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
try {
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
return
} catch (error) {
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
}
const entries = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<array>',
` <string>${this.escapeForPlist(resolvedPath)}</string>`,
'</array>',
'</plist>'
]
const plist = Buffer.from(entries.join('\n'), 'utf8')
clipboard.writeBuffer('NSFilenamesPboardType', plist)
const fileUrl = pathToFileURL(resolvedPath).toString()
clipboard.writeBuffer('public.file-url', Buffer.from(`${fileUrl}\n`, 'utf8'))
}
private async copyFileToClipboardLinux(resolvedPath: string): Promise<void> {
const fileUrl = pathToFileURL(resolvedPath).toString()
const content = `copy\n${fileUrl}`
clipboard.writeBuffer('x-special/gnome-copied-files', Buffer.from(content, 'utf8'))
clipboard.writeBuffer('text/uri-list', Buffer.from(`${fileUrl}\n`, 'utf8'))
}
private escapeForPlist(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
@IpcMethod()
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
return false
}
}
@IpcMethod()
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code === 'ENOENT') {
return null
}
throw error
})
if (!stats) {
return false
}
if (stats.isFile()) {
await fs.unlink(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
if (stats.isDirectory()) {
const entries = await fs.readdir(normalizedPath)
if (entries.length === 0) {
await fs.rmdir(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
}
return false
} catch (error) {
console.error('Failed to delete file:', error)
return false
}
}
}
export { FileSystemService }

View File

@@ -1,9 +1,6 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { DownloadHistoryItem } from '../../../shared/types'
import { historyManager } from '../../lib/history-manager'
import { settingsManager } from '../../settings'
class HistoryService extends IpcService {
static readonly groupName = 'history'
@@ -24,282 +21,10 @@ class HistoryService extends IpcService {
}
@IpcMethod()
async removeHistoryItem(_context: IpcContext, id: string, outputPath?: string): Promise<boolean> {
const record = historyManager.getHistoryById(id)
await this.deleteOutputResource(record, outputPath)
removeHistoryItem(_context: IpcContext, id: string): boolean {
return historyManager.removeHistoryItem(id)
}
private async deleteOutputResource(
record: DownloadHistoryItem | undefined,
fallbackOutputPath?: string
): Promise<void> {
try {
const candidatePaths = new Set<string>()
if (record?.outputPath) {
candidatePaths.add(record.outputPath)
}
if (fallbackOutputPath) {
candidatePaths.add(fallbackOutputPath)
}
for (const candidate of candidatePaths) {
if (await this.tryDeletePath(candidate)) {
return
}
}
const directories = this.collectCandidateDirectories(candidatePaths)
if (directories.length === 0) {
const defaultDownloadPath = settingsManager.get('downloadPath')
if (defaultDownloadPath) {
directories.push(defaultDownloadPath)
}
}
const matchKeys = this.collectMatchKeys(record, candidatePaths)
if (matchKeys.length === 0) {
return
}
const extensions = this.collectExtensions(candidatePaths, record)
for (const directory of directories) {
const matchedPath = await this.findMatchingFile(directory, matchKeys, extensions, record)
if (matchedPath && (await this.tryDeletePath(matchedPath))) {
return
}
}
} catch (error) {
console.error('Failed to delete output resource:', error)
}
}
private sanitizePath(target: string): string {
return target.trim().replace(/^['"]|['"]$/g, '')
}
private normalizeMatchString(value?: string): string {
if (!value) {
return ''
}
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '')
}
private collectCandidateDirectories(candidatePaths: Set<string>): string[] {
const directories = new Set<string>()
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
const normalized = path.normalize(sanitized)
if (!path.isAbsolute(normalized)) continue
const directory = path.dirname(normalized)
if (directory) {
directories.add(directory)
}
}
return Array.from(directories)
}
private collectExtensions(
candidatePaths: Set<string>,
record: DownloadHistoryItem | undefined
): Set<string> {
const extensions = new Set<string>()
const addExtension = (ext?: string) => {
if (!ext) {
return
}
const trimmed = ext.trim()
if (!trimmed) {
return
}
const normalized = trimmed.startsWith('.')
? trimmed.toLowerCase()
: `.${trimmed.toLowerCase()}`
extensions.add(normalized)
}
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
addExtension(path.extname(sanitized))
}
addExtension(record?.outputPath ? path.extname(record.outputPath) : undefined)
addExtension(record?.selectedFormat?.ext)
addExtension(record?.selectedFormat?.video_ext)
addExtension(record?.selectedFormat?.audio_ext)
if (extensions.size === 0) {
const fallback =
record?.type === 'audio'
? ['.mp3', '.m4a', '.aac', '.ogg', '.opus', '.flac', '.wav']
: ['.mp4', '.mkv', '.webm', '.mov', '.avi']
for (const ext of fallback) {
extensions.add(ext)
}
}
return extensions
}
private collectMatchKeys(
record: DownloadHistoryItem | undefined,
candidatePaths: Set<string>
): string[] {
const keys = new Set<string>()
const fallbackKeys: string[] = []
const tryAddKey = (value?: string) => {
if (!value) return
const normalized = this.normalizeMatchString(value)
if (!normalized) return
if (normalized.length >= 3) {
keys.add(normalized)
} else {
fallbackKeys.push(normalized)
}
}
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
const baseName = path.parse(sanitized).name
tryAddKey(baseName)
}
tryAddKey(record?.title)
tryAddKey(record?.id)
if (keys.size === 0) {
for (const key of fallbackKeys) {
keys.add(key)
}
}
return Array.from(keys)
}
private async findMatchingFile(
directory: string,
matchKeys: string[],
extensions: Set<string>,
record: DownloadHistoryItem | undefined
): Promise<string | null> {
try {
const dirStats = await fs.stat(directory).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code === 'ENOENT') {
return null
}
throw error
})
if (!dirStats || !dirStats.isDirectory()) {
return null
}
const entries = await fs.readdir(directory, { withFileTypes: true })
const matches: Array<{ path: string; diff: number }> = []
const targetTimestamp = record?.completedAt ?? record?.downloadedAt
const maxDiff = 10 * 60 * 1000 // 10 minutes
for (const entry of entries) {
if (!entry.isFile()) continue
const entryPath = path.join(directory, entry.name)
const entryExt = path.extname(entry.name).toLowerCase()
if (extensions.size > 0 && entryExt && !extensions.has(entryExt)) {
continue
}
const normalizedName = this.normalizeMatchString(entry.name)
if (!normalizedName && matchKeys.length > 0) continue
const hasMatchKeys = matchKeys.length > 0
const isMatch = hasMatchKeys
? matchKeys.some((key) => key && normalizedName.includes(key))
: true
if (!isMatch) continue
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats) continue
if (targetTimestamp) {
const diff = Math.abs(stats.mtimeMs - targetTimestamp)
if (diff > maxDiff) {
continue
}
matches.push({ path: entryPath, diff })
} else {
if (!hasMatchKeys) {
continue
}
matches.push({ path: entryPath, diff: Number.POSITIVE_INFINITY })
}
}
if (matches.length === 0) {
return null
}
matches.sort((a, b) => a.diff - b.diff)
return matches[0]?.path ?? null
} catch (error) {
console.error('Failed to search for matching file:', error)
return null
}
}
private async tryDeletePath(rawPath: string): Promise<boolean> {
const sanitizedPath = this.sanitizePath(rawPath)
if (!sanitizedPath) return false
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code === 'ENOENT') {
return null
}
throw error
})
if (!stats) {
return false
}
if (stats.isFile()) {
await fs.unlink(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
if (stats.isDirectory()) {
const entries = await fs.readdir(normalizedPath)
if (entries.length === 0) {
await fs.rmdir(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
}
return false
}
@IpcMethod()
getHistoryCount(_context: IpcContext): {
active: number

View File

@@ -1,42 +1,92 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray'
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
import { applyDockVisibility } from '../../utils/dock'
class SettingsService extends IpcService {
static readonly groupName = 'settings'
@IpcMethod()
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
return settingsManager.get(key)
const value = settingsManager.get(key)
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
return sanitizeFilenameTemplate(value) as AppSettings[K]
}
return value
}
@IpcMethod()
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
settingsManager.set(key, value)
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
} else {
settingsManager.set(key, value)
}
if (key === 'language') {
updateTrayMenu()
}
if (key === 'hideDockIcon') {
applyDockVisibility(value as AppSettings['hideDockIcon'])
}
if (key === 'launchAtLogin') {
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
}
if (key === 'subscriptionCheckIntervalHours') {
subscriptionScheduler.refreshInterval()
}
}
@IpcMethod()
getAll(_context: IpcContext): AppSettings {
return settingsManager.getAll()
const settings = settingsManager.getAll()
if (typeof settings.subscriptionFilenameTemplate === 'string') {
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
settings.subscriptionFilenameTemplate
)
}
return settings
}
@IpcMethod()
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
if (typeof settings.subscriptionFilenameTemplate === 'string') {
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
settings.subscriptionFilenameTemplate
)
}
settingsManager.setAll(settings)
if (settings.language) {
updateTrayMenu()
}
if (typeof settings.hideDockIcon === 'boolean') {
applyDockVisibility(settings.hideDockIcon)
}
if (typeof settings.launchAtLogin === 'boolean') {
applyAutoLaunchSetting(settings.launchAtLogin)
}
if (settings.subscriptionCheckIntervalHours !== undefined) {
subscriptionScheduler.refreshInterval()
}
}
@IpcMethod()
reset(_context: IpcContext): void {
settingsManager.reset()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
subscriptionScheduler.refreshInterval()
}
}

View File

@@ -0,0 +1,164 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type {
SubscriptionCreatePayload,
SubscriptionResolvedFeed,
SubscriptionRule,
SubscriptionUpdatePayload
} from '../../../shared/types'
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
import { settingsManager } from '../../settings'
interface CreateSubscriptionOptions {
url: string
keywords?: string[]
tags?: string[]
onlyDownloadLatest?: boolean
downloadDirectory?: string
namingTemplate?: string
enabled?: boolean
}
const ensureUrlHasProtocol = (value: string): string => {
if (!value) {
return value
}
if (!/^https?:\/\//i.test(value)) {
return `https://${value}`
}
return value
}
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
const normalized = ensureUrlHasProtocol(rawUrl.trim())
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
if (youTubeChannelMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
platform: 'youtube'
}
}
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'youtube'
}
}
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
if (youTubeUserMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
platform: 'youtube'
}
}
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
if (youTubeHandleMatch) {
const handle = youTubeHandleMatch[1].replace('@', '')
return {
sourceUrl: normalized,
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
platform: 'youtube'
}
}
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
if (biliSpaceMatch) {
return {
sourceUrl: normalized,
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
platform: 'bilibili'
}
}
if (/rsshub\.app\/bilibili/i.test(normalized)) {
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'bilibili'
}
}
return {
sourceUrl: normalized,
feedUrl: normalized,
platform: 'custom'
}
}
class SubscriptionService extends IpcService {
static readonly groupName = 'subscriptions'
@IpcMethod()
list(_context: IpcContext): SubscriptionRule[] {
return subscriptionManager.getAll()
}
@IpcMethod()
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
return resolveFeedFromInput(url)
}
@IpcMethod()
async create(
_context: IpcContext,
options: CreateSubscriptionOptions
): Promise<SubscriptionRule> {
const resolved = resolveFeedFromInput(options.url)
const settings = settingsManager.getAll()
const payload: SubscriptionCreatePayload = {
sourceUrl: resolved.sourceUrl,
feedUrl: resolved.feedUrl,
platform: resolved.platform,
keywords: options.keywords,
tags: options.tags,
onlyDownloadLatest:
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
downloadDirectory: options.downloadDirectory || settings.downloadPath,
namingTemplate: sanitizeFilenameTemplate(
options.namingTemplate || settings.subscriptionFilenameTemplate
),
enabled: options.enabled ?? true
}
const created = subscriptionManager.add(payload)
void subscriptionScheduler.runNow(created.id)
return created
}
@IpcMethod()
update(
_context: IpcContext,
id: string,
updates: SubscriptionUpdatePayload
): SubscriptionRule | undefined {
const normalized: SubscriptionUpdatePayload = { ...updates }
if (typeof normalized.namingTemplate === 'string') {
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
}
return subscriptionManager.update(id, normalized)
}
@IpcMethod()
remove(_context: IpcContext, id: string): boolean {
return subscriptionManager.remove(id)
}
@IpcMethod()
async refresh(_context: IpcContext, id?: string): Promise<void> {
await subscriptionScheduler.runNow(id)
}
@IpcMethod()
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
return subscriptionScheduler.queueItem(id, itemId)
}
}
export { SubscriptionService }

View File

@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { autoUpdater } from 'electron-updater'
import { settingsManager } from '../../settings'
const isNewerVersion = (latest: string, current: string): boolean => {
const toSegments = (version: string) =>
version.split(/[.-]/).map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isNaN(parsed) ? 0 : parsed
})
const latestSegments = toSegments(latest)
const currentSegments = toSegments(current)
const maxLength = Math.max(latestSegments.length, currentSegments.length)
for (let index = 0; index < maxLength; index += 1) {
const latestValue = latestSegments[index] ?? 0
const currentValue = currentSegments[index] ?? 0
if (latestValue > currentValue) {
return true
}
if (latestValue < currentValue) {
return false
}
}
return false
}
class UpdateService extends IpcService {
static readonly groupName = 'update'
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
_context: IpcContext
): Promise<{ available: boolean; version?: string; error?: string }> {
try {
// In production, use checkForUpdatesAndNotify for automatic notifications
const result = await autoUpdater.checkForUpdatesAndNotify()
const currentVersion = app.getVersion()
const result = await autoUpdater.checkForUpdates()
const latestVersion = result?.updateInfo?.version
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
return {
available: true,
version: latestVersion
}
}
return {
available: result !== null,
version: result?.updateInfo?.version
available: false,
version: latestVersion ?? currentVersion
}
} catch (error) {
return {

View File

@@ -0,0 +1,6 @@
import { join } from 'node:path'
import { app } from 'electron'
export const getDatabaseFilePath = (): string => {
return join(app.getPath('userData'), 'vidbee.db')
}

View File

@@ -0,0 +1,97 @@
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { type MigrationMeta, readMigrationFiles } from 'drizzle-orm/migrator'
import { app } from 'electron'
import log from 'electron-log/main'
const MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'
const MIGRATIONS_TABLE = '__drizzle_migrations'
const KNOWN_TABLES = ['download_history', 'subscriptions', 'subscription_items']
const migrationsTableDefinition = sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(MIGRATIONS_TABLE)} (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)
`
export const runMigrations = (database: BetterSQLite3Database): void => {
const migrationsFolder = resolveMigrationsFolder()
if (!migrationsFolder) {
log.warn('database: drizzle migrations folder not found, skipping migrations')
return
}
try {
ensureBaseline(database, migrationsFolder)
migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
} catch (error) {
log.error('database: failed to run drizzle migrations', error)
}
}
const resolveMigrationsFolder = (): string | null => {
const candidates = new Set<string>()
candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))
candidates.add(resolve(__dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))
if (process.resourcesPath) {
candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))
candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))
}
try {
candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))
} catch {
// app might not be ready yet, ignore
}
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
const ensureBaseline = (database: BetterSQLite3Database, migrationsFolder: string): void => {
if (hasTable(database, MIGRATIONS_TABLE)) {
return
}
const hasExistingSchema = KNOWN_TABLES.some((table) => hasTable(database, table))
if (!hasExistingSchema) {
return
}
log.info('database: detected existing schema, seeding drizzle migrations baseline')
database.run(migrationsTableDefinition)
let migrations: MigrationMeta[]
try {
migrations = readMigrationFiles({ migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
} catch (error) {
log.error('database: failed to read drizzle migrations while seeding baseline', error)
return
}
database.transaction((tx) => {
for (const migration of migrations) {
tx.run(
sql`INSERT INTO ${sql.identifier(MIGRATIONS_TABLE)} ("hash", "created_at") VALUES(${migration.hash}, ${migration.folderMillis})`
)
}
})
}
const hasTable = (database: BetterSQLite3Database, tableName: string): boolean => {
const rows = database.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}`
)
return rows.length > 0
}

View File

@@ -0,0 +1,83 @@
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const downloadHistoryTable = sqliteTable('download_history', {
id: text('id').primaryKey(),
url: text('url').notNull(),
title: text('title').notNull(),
thumbnail: text('thumbnail'),
type: text('type').notNull(),
status: text('status').notNull(),
downloadPath: text('download_path'),
savedFileName: text('saved_file_name'),
fileSize: integer('file_size', { mode: 'number' }),
duration: integer('duration', { mode: 'number' }),
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
error: text('error'),
description: text('description'),
channel: text('channel'),
uploader: text('uploader'),
viewCount: integer('view_count', { mode: 'number' }),
tags: text('tags'),
origin: text('origin'),
subscriptionId: text('subscription_id'),
selectedFormat: text('selected_format'),
playlistId: text('playlist_id'),
playlistTitle: text('playlist_title'),
playlistIndex: integer('playlist_index', { mode: 'number' }),
playlistSize: integer('playlist_size', { mode: 'number' })
})
export const subscriptionsTable = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
title: text('title').notNull(),
sourceUrl: text('source_url').notNull(),
feedUrl: text('feed_url').notNull(),
platform: text('platform').notNull(),
keywords: text('keywords').notNull(),
tags: text('tags').notNull(),
onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),
enabled: integer('enabled', { mode: 'number' }).notNull(),
coverUrl: text('cover_url'),
latestVideoTitle: text('latest_video_title'),
latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),
lastCheckedAt: integer('last_checked_at', { mode: 'number' }),
lastSuccessAt: integer('last_success_at', { mode: 'number' }),
status: text('status').notNull(),
lastError: text('last_error'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
downloadDirectory: text('download_directory'),
namingTemplate: text('naming_template')
})
export const subscriptionItemsTable = sqliteTable(
'subscription_items',
{
subscriptionId: text('subscription_id').notNull(),
itemId: text('item_id').notNull(),
title: text('title').notNull(),
url: text('url').notNull(),
publishedAt: integer('published_at', { mode: 'number' }).notNull(),
thumbnail: text('thumbnail'),
added: integer('added', { mode: 'number' }).notNull(),
downloadId: text('download_id'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull()
},
(table) => ({
pk: primaryKey({
columns: [table.subscriptionId, table.itemId],
name: 'subscription_items_pk'
}),
subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)
})
)
export type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
export type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
export type SubscriptionRow = typeof subscriptionsTable.$inferSelect
export type SubscriptionInsert = typeof subscriptionsTable.$inferInsert
export type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect
export type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
class FfmpegManager {
private ffmpegPath: string | null = null
async initialize(): Promise<void> {
this.ffmpegPath = await this.findFfmpegBinary()
console.log('ffmpeg initialized at:', this.ffmpegPath)
}
getPath(): string {
if (!this.ffmpegPath) {
throw new Error('ffmpeg not initialized. Call initialize() first.')
}
return this.ffmpegPath
}
private getResourcesPath(): string {
if (process.env.NODE_ENV === 'development') {
return path.join(process.cwd(), 'resources')
}
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
}
private async findFfmpegBinary(): Promise<string> {
const platform = os.platform()
const resourceCandidates: string[] = []
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
return process.env.FFMPEG_PATH
}
if (platform === 'win32') {
resourceCandidates.push('ffmpeg.exe')
} else if (platform === 'darwin') {
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
} else {
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
}
const resourcesPath = this.getResourcesPath()
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission on ffmpeg binary:', error)
}
}
console.log('Using bundled ffmpeg:', fullPath)
return fullPath
}
}
if (platform === 'darwin') {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for (const candidate of commonPaths) {
if (fs.existsSync(candidate)) {
console.log('Using system ffmpeg:', candidate)
return candidate
}
}
}
if (platform === 'linux' || platform === 'freebsd') {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system ffmpeg:', systemPath)
return systemPath
}
} catch (_error) {
// Ignore error and continue
}
}
if (platform === 'win32') {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
console.log('Using system ffmpeg:', output)
return output
}
} catch (_error) {
// Ignore error and continue
}
}
throw new Error(
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
)
}
}
export const ffmpegManager = new FfmpegManager()

View File

@@ -1,52 +1,476 @@
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import DatabaseConstructor from 'better-sqlite3'
import { eq, sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { app } from 'electron'
import log from 'electron-log/main'
import type { DownloadHistoryItem } from '../../shared/types'
import { runMigrations } from './database/migrate'
import {
type DownloadHistoryInsert,
type DownloadHistoryRow,
downloadHistoryTable
} from './database/schema'
import { getDatabaseFilePath } from './database-path'
const logger = log.scope('history-manager')
const TAG_SEPARATOR = '\n'
const createDownloadHistoryTableSql = sql`
CREATE TABLE IF NOT EXISTS download_history (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
thumbnail TEXT,
type TEXT NOT NULL,
status TEXT NOT NULL,
download_path TEXT,
saved_file_name TEXT,
file_size INTEGER,
duration INTEGER,
downloaded_at INTEGER NOT NULL,
completed_at INTEGER,
sort_key INTEGER NOT NULL,
error TEXT,
description TEXT,
channel TEXT,
uploader TEXT,
view_count INTEGER,
tags TEXT,
origin TEXT,
subscription_id TEXT,
selected_format TEXT,
playlist_id TEXT,
playlist_title TEXT,
playlist_index INTEGER,
playlist_size INTEGER
)
`
const renameDownloadHistoryTableSql = sql`
ALTER TABLE download_history RENAME TO download_history_legacy
`
const dropLegacyDownloadHistoryTableSql = sql`
DROP TABLE download_history_legacy
`
const copyDownloadHistoryFromLegacySql = sql`
INSERT INTO download_history (
id,
url,
title,
thumbnail,
type,
status,
download_path,
saved_file_name,
file_size,
duration,
downloaded_at,
completed_at,
sort_key,
error,
description,
channel,
uploader,
view_count,
tags,
origin,
subscription_id,
selected_format,
playlist_id,
playlist_title,
playlist_index,
playlist_size
)
SELECT
id,
url,
title,
thumbnail,
type,
status,
download_path,
saved_file_name,
file_size,
duration,
downloaded_at,
completed_at,
sort_key,
error,
description,
channel,
uploader,
view_count,
tags,
origin,
subscription_id,
selected_format,
playlist_id,
playlist_title,
playlist_index,
playlist_size
FROM download_history_legacy
`
const sanitizeList = (values?: string[]): string[] => {
if (!values || values.length === 0) {
return []
}
return values
.map((value) => value.trim())
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
}
const serializeTags = (values?: string[]): string | null => {
const sanitized = sanitizeList(values)
return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null
}
const parseTags = (value: string | null): string[] | undefined => {
if (!value) {
return undefined
}
const parsed = value
.split(TAG_SEPARATOR)
.map((tag) => tag.trim())
.filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)
return parsed.length > 0 ? parsed : undefined
}
const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
id: text('id').primaryKey(),
status: text('status').notNull(),
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
payload: text('payload').notNull()
})
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
class HistoryManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
private db: BetterSQLite3Database | null = null
private history: Map<string, DownloadHistoryItem> = new Map()
private schemaChecked = false
private migrationChecked = false
constructor() {
this.store = new Store({
name: 'download-history',
defaults: {
items: []
}
})
this.loadHistory()
this.initialize()
}
private loadHistory(): void {
private initialize(): void {
try {
const historyArray = this.store.get('items', [])
this.history = new Map(historyArray.map((item) => [item.id, item]))
this.getDatabase()
this.ensureStructuredSchema()
this.ensureLegacyMigration()
this.loadHistoryFromDatabase()
} catch (error) {
console.error('Failed to load download history:', error)
logger.error('history-db failed to initialize', error)
}
}
private saveHistory(): void {
private getDatabase(): BetterSQLite3Database {
if (this.db) {
return this.db
}
const databasePath = this.getDatabasePath()
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
const database = drizzle(sqlite)
runMigrations(database)
this.db = database
logger.info(`history-db initialized at ${databasePath}`)
return this.db
}
private getDatabasePath(): string {
return getDatabaseFilePath()
}
private getLegacyStorePath(): string {
return join(app.getPath('userData'), 'download-history.json')
}
private ensureStructuredSchema(): void {
if (this.schemaChecked) {
return
}
this.schemaChecked = true
try {
const historyArray = Array.from(this.history.values())
this.store.set('items', historyArray)
const database = this.getDatabase()
const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`)
const hasPayloadColumn = columns.some((column) => column.name === 'payload')
const hasUrlColumn = columns.some((column) => column.name === 'url')
if (hasPayloadColumn || !hasUrlColumn) {
this.migrateLegacyPayloadTable()
return
}
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
if (needsRebuild) {
this.rebuildDownloadHistoryTable()
}
} catch (error) {
console.error('Failed to save download history:', error)
logger.error('history-db failed to inspect schema', error)
}
}
private migrateLegacyPayloadTable(): void {
const database = this.getDatabase()
logger.info('history-db migrating legacy payload schema to structured columns')
try {
let migratedCount = 0
database.transaction(
(tx) => {
tx.run(renameDownloadHistoryTableSql)
tx.run(createDownloadHistoryTableSql)
const legacyRows = tx.select().from(legacyDownloadHistoryTable).all()
migratedCount = legacyRows.length
for (const legacyRow of legacyRows) {
const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow))
tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run()
}
tx.run(dropLegacyDownloadHistoryTableSql)
},
{ behavior: 'immediate' }
)
logger.info(`history-db migrated ${migratedCount} rows to new schema`)
} catch (error) {
logger.error('history-db failed to migrate legacy payload rows', error)
throw error
}
}
private rebuildDownloadHistoryTable(): void {
const database = this.getDatabase()
logger.info('history-db rebuilding download_history table to latest schema')
try {
database.transaction(
(tx) => {
tx.run(renameDownloadHistoryTableSql)
tx.run(createDownloadHistoryTableSql)
tx.run(copyDownloadHistoryFromLegacySql)
tx.run(dropLegacyDownloadHistoryTableSql)
},
{ behavior: 'immediate' }
)
logger.info('history-db rebuilt download_history table')
} catch (error) {
logger.error('history-db failed to rebuild download_history schema', error)
throw error
}
}
private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem {
try {
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
return {
...parsed,
status: (parsed.status ?? row.status) as DownloadHistoryItem['status'],
downloadedAt: parsed.downloadedAt ?? row.downloadedAt,
completedAt: parsed.completedAt ?? row.completedAt ?? undefined
}
} catch (error) {
logger.warn('history-db falling back while migrating payload row', { id: row.id, error })
return {
id: row.id,
url: row.id,
title: `Download ${row.id}`,
type: 'video',
status: row.status as DownloadHistoryItem['status'],
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined
}
}
}
private ensureLegacyMigration(): void {
if (this.migrationChecked) {
return
}
this.migrationChecked = true
const legacyPath = this.getLegacyStorePath()
if (!existsSync(legacyPath)) {
return
}
try {
const raw = readFileSync(legacyPath, 'utf-8')
const parsed = JSON.parse(raw) as { items?: DownloadHistoryItem[] }
const items = parsed.items ?? []
if (items.length === 0) {
return
}
const database = this.getDatabase()
for (const legacyItem of items) {
const normalized = this.normalizeItem(legacyItem)
const insertPayload = this.mapItemToInsert(normalized)
database
.insert(downloadHistoryTable)
.values(insertPayload)
.onConflictDoUpdate({
target: downloadHistoryTable.id,
set: this.mapItemToUpdate(insertPayload)
})
.run()
}
try {
renameSync(legacyPath, `${legacyPath}.bak`)
} catch (renameError) {
logger.warn(
'history-db migrated legacy store but could not rename original file',
renameError
)
}
logger.info(`history-db migrated ${items.length} entries from legacy electron-store data`)
} catch (error) {
logger.error('history-db failed to migrate legacy store', error)
}
}
private loadHistoryFromDatabase(): void {
try {
const database = this.getDatabase()
const rows = database.select().from(downloadHistoryTable).all()
this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))
} catch (error) {
logger.error('history-db failed to load rows', error)
this.history = new Map()
}
}
private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {
const fallbackTimestamp = Date.now()
const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp
const status = item.status ?? 'pending'
return {
...item,
status,
downloadedAt
}
}
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
return {
id: item.id,
url: item.url,
title: item.title,
thumbnail: item.thumbnail ?? null,
type: item.type,
status: item.status,
downloadPath: item.downloadPath ?? null,
savedFileName: item.savedFileName ?? null,
fileSize: item.fileSize ?? null,
duration: item.duration ?? null,
downloadedAt: item.downloadedAt,
completedAt: item.completedAt ?? null,
sortKey: item.completedAt ?? item.downloadedAt,
error: item.error ?? null,
description: item.description ?? null,
channel: item.channel ?? null,
uploader: item.uploader ?? null,
viewCount: item.viewCount ?? null,
tags: serializeTags(item.tags) ?? null,
origin: item.origin ?? null,
subscriptionId: item.subscriptionId ?? null,
selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,
playlistId: item.playlistId ?? null,
playlistTitle: item.playlistTitle ?? null,
playlistIndex: item.playlistIndex ?? null,
playlistSize: item.playlistSize ?? null
}
}
private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {
const { id: _id, ...rest } = payload
return rest
}
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
let selectedFormat: DownloadHistoryItem['selectedFormat']
if (row.selectedFormat) {
try {
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
} catch (error) {
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
}
}
const tags = parseTags(row.tags ?? null)
return {
id: row.id,
url: row.url,
title: row.title,
thumbnail: row.thumbnail ?? undefined,
type: row.type as DownloadHistoryItem['type'],
status: row.status as DownloadHistoryItem['status'],
downloadPath: row.downloadPath ?? undefined,
savedFileName: row.savedFileName ?? undefined,
fileSize: row.fileSize ?? undefined,
duration: row.duration ?? undefined,
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined,
error: row.error ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,
viewCount: row.viewCount ?? undefined,
tags,
origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,
subscriptionId: row.subscriptionId ?? undefined,
selectedFormat,
playlistId: row.playlistId ?? undefined,
playlistTitle: row.playlistTitle ?? undefined,
playlistIndex: row.playlistIndex ?? undefined,
playlistSize: row.playlistSize ?? undefined
}
}
addHistoryItem(item: DownloadHistoryItem): void {
this.history.set(item.id, item)
this.saveHistory()
const normalized = this.normalizeItem(item)
const insertPayload = this.mapItemToInsert(normalized)
try {
const database = this.getDatabase()
database
.insert(downloadHistoryTable)
.values(insertPayload)
.onConflictDoUpdate({
target: downloadHistoryTable.id,
set: this.mapItemToUpdate(insertPayload)
})
.run()
this.history.set(normalized.id, normalized)
} catch (error) {
logger.error('history-db failed to upsert item', { id: normalized.id, error })
}
}
getHistory(): DownloadHistoryItem[] {
return Array.from(this.history.values()).sort((a, b) => {
const aTime = a.completedAt || a.downloadedAt
const bTime = b.completedAt || b.downloadedAt
const aTime = a.completedAt ?? a.downloadedAt
const bTime = b.completedAt ?? b.downloadedAt
return bTime - aTime
})
}
@@ -56,30 +480,52 @@ class HistoryManager {
}
removeHistoryItem(id: string): boolean {
const deleted = this.history.delete(id)
if (deleted) {
this.saveHistory()
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.id, id))
.run()
const removedFromMap = this.history.delete(id)
return result.changes > 0 || removedFromMap
} catch (error) {
logger.error('history-db failed to delete item', { id, error })
return false
}
return deleted
}
clearHistory(): void {
this.history.clear()
this.saveHistory()
try {
const database = this.getDatabase()
database.delete(downloadHistoryTable).run()
this.history.clear()
} catch (error) {
logger.error('history-db failed to clear items', error)
}
}
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
let removedCount = 0
for (const [id, item] of this.history.entries()) {
if (item.status === status) {
this.history.delete(id)
removedCount++
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.status, status))
.run()
for (const [id, item] of this.history.entries()) {
if (item.status === status) {
this.history.delete(id)
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to clear items by status', { status, error })
return removedCount
}
if (removedCount > 0) {
this.saveHistory()
}
return removedCount
}
getHistoryCount(): {
@@ -111,6 +557,15 @@ class HistoryManager {
return counts
}
hasHistoryForUrl(url: string): boolean {
for (const item of this.history.values()) {
if (item.url === url) {
return true
}
}
return false
}
}
export const historyManager = new HistoryManager()

View File

@@ -0,0 +1,641 @@
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
import DatabaseConstructor from 'better-sqlite3'
import { and, desc, eq, inArray } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import log from 'electron-log/main'
import type {
SubscriptionCreatePayload,
SubscriptionFeedItem,
SubscriptionRule,
SubscriptionStatus,
SubscriptionUpdatePayload
} from '../../shared/types'
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
import { runMigrations } from './database/migrate'
import {
type SubscriptionInsert,
type SubscriptionItemRow,
type SubscriptionRow,
subscriptionItemsTable,
subscriptionsTable
} from './database/schema'
import { getDatabaseFilePath } from './database-path'
const sanitizeList = (values?: string[]): string[] => {
if (!values || values.length === 0) {
return []
}
return values
.map((value) => value.trim())
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
}
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
log.error('Failed to ensure subscription directory:', error)
}
}
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
const parseStringArray = (value: string | null | undefined): string[] => {
if (!value) {
return []
}
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
} catch {
return []
}
}
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
export class SubscriptionManager extends EventEmitter {
private sqlite: BetterSqlite3Instance | null = null
private db: BetterSQLite3Database | null = null
constructor() {
super()
try {
this.getDatabase()
this.migrateLegacyStore()
} catch (error) {
log.error('subscriptions: failed to initialize database', error)
}
}
getAll(): SubscriptionRule[] {
const database = this.getDatabase()
const rows = database
.select()
.from(subscriptionsTable)
.orderBy(desc(subscriptionsTable.updatedAt))
.all()
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
}
getById(id: string): SubscriptionRule | undefined {
const database = this.getDatabase()
const row = database
.select()
.from(subscriptionsTable)
.where(eq(subscriptionsTable.id, id))
.get()
if (!row) {
return undefined
}
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
}
add(payload: SubscriptionCreatePayload): SubscriptionRule {
const timestamp = Date.now()
const keywords = sanitizeList(payload.keywords)
const tags = sanitizeList(payload.tags)
const record: SubscriptionRule = {
id: randomUUID(),
title: payload.sourceUrl,
sourceUrl: payload.sourceUrl,
feedUrl: payload.feedUrl,
platform: payload.platform,
keywords,
tags,
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
enabled: payload.enabled ?? true,
coverUrl: undefined,
latestVideoTitle: undefined,
latestVideoPublishedAt: undefined,
lastCheckedAt: undefined,
lastSuccessAt: undefined,
status: 'idle',
lastError: undefined,
createdAt: timestamp,
updatedAt: timestamp,
downloadDirectory: payload.downloadDirectory,
namingTemplate: payload.namingTemplate
? sanitizeFilenameTemplate(payload.namingTemplate)
: undefined,
items: []
}
ensureDirectoryExists(payload.downloadDirectory)
this.insertRecord(record)
this.emitUpdates()
return record
}
update(
id: string,
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
): SubscriptionRule | undefined {
const existing = this.getById(id)
if (!existing) {
return undefined
}
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
const next: SubscriptionRule = {
...existing,
...updates,
keywords: keywords ?? existing.keywords,
tags: tags ?? existing.tags,
updatedAt: Date.now()
}
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
next.title = updates.sourceUrl
}
if (updates.namingTemplate) {
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
}
ensureDirectoryExists(next.downloadDirectory)
this.updateRecord(next)
this.emitUpdates()
return next
}
remove(id: string): boolean {
const database = this.getDatabase()
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
if ((result.changes ?? 0) > 0) {
database
.delete(subscriptionItemsTable)
.where(eq(subscriptionItemsTable.subscriptionId, id))
.run()
this.emitUpdates()
return true
}
return false
}
replaceFeedItems(
subscriptionId: string,
items: SubscriptionFeedItem[],
silent: boolean = false
): void {
const database = this.getDatabase()
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
const now = Date.now()
database.transaction((tx) => {
tx.delete(subscriptionItemsTable)
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
.run()
for (const item of orderedItems) {
tx.insert(subscriptionItemsTable)
.values({
subscriptionId,
itemId: item.id,
title: item.title,
url: item.url,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail ?? null,
added: booleanToNumber(item.addedToQueue),
downloadId: item.downloadId ?? null,
createdAt: item.publishedAt,
updatedAt: now
})
.run()
}
})
if (!silent) {
this.emitUpdates()
}
}
updateFeedItemQueueState(
subscriptionId: string,
itemId: string,
updates: { added?: boolean; downloadId?: string | null }
): void {
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
return
}
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
updatedAt: Date.now()
}
if (updates.added !== undefined) {
setPayload.added = booleanToNumber(updates.added)
}
if (Object.hasOwn(updates, 'downloadId')) {
setPayload.downloadId = updates.downloadId ?? null
}
const database = this.getDatabase()
const result = database
.update(subscriptionItemsTable)
.set(setPayload)
.where(
and(
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
eq(subscriptionItemsTable.itemId, itemId)
)
)
.run()
if ((result.changes ?? 0) > 0) {
this.emitUpdates()
}
}
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
if (records.length === 0) {
return records
}
const ids = records.map((record) => record.id)
const database = this.getDatabase()
const rows = database
.select()
.from(subscriptionItemsTable)
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
.orderBy(desc(subscriptionItemsTable.publishedAt))
.all()
const grouped = new Map<string, SubscriptionFeedItem[]>()
for (const row of rows) {
const item = this.mapItemRowToFeedItem(row)
const list = grouped.get(row.subscriptionId)
if (list) {
list.push(item)
} else {
grouped.set(row.subscriptionId, [item])
}
}
return records.map((record) => ({
...record,
items: grouped.get(record.id) ?? []
}))
}
private getDatabase(): BetterSQLite3Database {
if (this.db) {
return this.db
}
const databasePath = this.getDatabasePath()
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
this.sqlite.pragma('journal_mode = WAL')
this.sqlite.pragma('foreign_keys = ON')
this.db = drizzle(this.sqlite)
runMigrations(this.db)
this.ensureSubscriptionsSchema()
this.ensureItemsSchema()
log.info('subscriptions: database initialized at', databasePath)
return this.db
}
private ensureItemsSchema(): void {
if (!this.sqlite) {
return
}
try {
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
name: string
}>
const hasAdded = columns.some((column) => column.name === 'added')
if (!hasAdded) {
this.sqlite
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
.run()
}
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
const hasLegacyStatus = columns.some((column) => column.name === 'status')
const needsMigration = hasDownloaded || hasLegacyStatus
if (needsMigration) {
if (hasDownloaded) {
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
}
const sqlite = this.sqlite
const migrate = sqlite.transaction(() => {
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
sqlite
.prepare(
`CREATE TABLE subscription_items_new (
subscription_id TEXT NOT NULL,
item_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
published_at INTEGER NOT NULL,
thumbnail TEXT,
added INTEGER NOT NULL,
download_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (subscription_id, item_id)
)`
)
.run()
sqlite
.prepare(
`INSERT INTO subscription_items_new (
subscription_id,
item_id,
title,
url,
published_at,
thumbnail,
added,
download_id,
created_at,
updated_at
)
SELECT
subscription_id,
item_id,
title,
url,
published_at,
thumbnail,
added,
download_id,
created_at,
updated_at
FROM subscription_items`
)
.run()
sqlite.prepare(`DROP TABLE subscription_items`).run()
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
sqlite
.prepare(
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
)
.run()
})
migrate()
log.info('subscriptions: removed legacy columns from subscription_items')
}
} catch (error) {
log.warn('subscriptions: failed to ensure subscription_items schema', error)
}
}
private ensureSubscriptionsSchema(): void {
if (!this.sqlite) {
return
}
try {
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
name: string
}>
const hasLegacyColumns = columns.some((column) =>
['seen_item_ids', 'last_item_id'].includes(column.name)
)
if (!hasLegacyColumns) {
return
}
const sqlite = this.sqlite
const migrate = sqlite.transaction(() => {
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
sqlite
.prepare(
`CREATE TABLE subscriptions_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_url TEXT NOT NULL,
feed_url TEXT NOT NULL,
platform TEXT NOT NULL,
keywords TEXT NOT NULL,
tags TEXT NOT NULL,
only_latest INTEGER NOT NULL,
enabled INTEGER NOT NULL,
cover_url TEXT,
latest_video_title TEXT,
latest_video_published_at INTEGER,
last_checked_at INTEGER,
last_success_at INTEGER,
status TEXT NOT NULL,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
download_directory TEXT,
naming_template TEXT
)`
)
.run()
sqlite
.prepare(
`INSERT INTO subscriptions_new (
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
)
SELECT
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
FROM subscriptions`
)
.run()
sqlite.prepare(`DROP TABLE subscriptions`).run()
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
})
migrate()
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
} catch (error) {
log.warn('subscriptions: failed to ensure subscriptions schema', error)
}
}
private getDatabasePath(): string {
return getDatabaseFilePath()
}
private migrateLegacyStore(): void {
try {
const LegacyStore = require('electron-store')
const store = new LegacyStore({
name: 'subscriptions',
defaults: {
subscriptions: []
}
})
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
if (!legacyData || legacyData.length === 0) {
return
}
if (this.getAll().length > 0) {
return
}
for (const legacyItem of legacyData) {
try {
const normalized: SubscriptionRule = {
...legacyItem,
keywords: sanitizeList(legacyItem.keywords),
tags: sanitizeList(legacyItem.tags),
items: []
}
this.insertRecord(normalized)
const legacyFeedItemsRaw = Array.isArray(
(legacyItem as { items?: SubscriptionFeedItem[] }).items
)
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
: []
if (legacyFeedItemsRaw.length > 0) {
const converted = legacyFeedItemsRaw.map((item) => ({
id: item.id,
url: item.url,
title: item.title,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail,
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
downloadId: item.downloadId
}))
this.replaceFeedItems(normalized.id, converted, true)
}
} catch (error) {
log.error('subscriptions: failed to migrate legacy record', error)
}
}
store.clear()
this.emitUpdates()
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
} catch (error) {
log.warn('subscriptions: legacy migration skipped', error)
}
}
private insertRecord(record: SubscriptionRule): void {
const database = this.getDatabase()
const payload = this.mapRecordToInsert(record)
database
.insert(subscriptionsTable)
.values(payload)
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
.run()
}
private updateRecord(record: SubscriptionRule): void {
const database = this.getDatabase()
const payload = this.mapRecordToInsert(record)
database
.insert(subscriptionsTable)
.values(payload)
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
.run()
}
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
return {
id: record.id,
title: record.title,
sourceUrl: record.sourceUrl,
feedUrl: record.feedUrl,
platform: record.platform,
keywords: stringifyArray(record.keywords),
tags: stringifyArray(record.tags),
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
enabled: booleanToNumber(record.enabled),
coverUrl: record.coverUrl,
latestVideoTitle: record.latestVideoTitle,
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
lastCheckedAt: record.lastCheckedAt ?? null,
lastSuccessAt: record.lastSuccessAt ?? null,
status: record.status,
lastError: record.lastError,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
downloadDirectory: record.downloadDirectory,
namingTemplate: record.namingTemplate
? sanitizeFilenameTemplate(record.namingTemplate)
: undefined
}
}
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
return {
id: row.id,
title: row.title,
sourceUrl: row.sourceUrl,
feedUrl: row.feedUrl,
platform: row.platform as SubscriptionRule['platform'],
keywords: parseStringArray(row.keywords),
tags: parseStringArray(row.tags),
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
enabled: numberToBoolean(row.enabled),
coverUrl: row.coverUrl ?? undefined,
latestVideoTitle: row.latestVideoTitle ?? undefined,
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
lastCheckedAt: row.lastCheckedAt ?? undefined,
lastSuccessAt: row.lastSuccessAt ?? undefined,
status: row.status as SubscriptionStatus,
lastError: row.lastError ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
downloadDirectory: row.downloadDirectory ?? undefined,
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
items: []
}
}
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
return {
id: row.itemId,
url: row.url,
title: row.title,
publishedAt: row.publishedAt,
thumbnail: row.thumbnail ?? undefined,
addedToQueue: numberToBoolean(row.added),
downloadId: row.downloadId ?? undefined
}
}
private emitUpdates(): void {
this.emit('subscriptions:updated', this.getAll())
}
}
export const subscriptionManager = new SubscriptionManager()

View File

@@ -0,0 +1,458 @@
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import log from 'electron-log/main'
import Parser from 'rss-parser'
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
import { settingsManager } from '../settings'
import { downloadEngine } from './download-engine'
import { historyManager } from './history-manager'
import { subscriptionManager } from './subscription-manager'
const logger = log.scope('subscriptions')
type ParserItem = {
title?: string
link?: string
guid?: string
id?: string
isoDate?: string
pubDate?: string
youtubeId?: string
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
mediaContent?: Array<{ url?: string }> | { url?: string }
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
[key: string]: unknown
}
type TrackedDownload = {
subscriptionId: string
itemId: string
url: string
retries: number
downloadId: string
}
type FeedItem = {
id: string
url: string
title: string
publishedAt: number
thumbnail?: string
}
const parser = new Parser<{ item: ParserItem }>({
customFields: {
item: [
['yt:videoId', 'youtubeId'],
['media:thumbnail', 'mediaThumbnail'],
['media:content', 'mediaContent'],
['enclosure', 'enclosure']
]
}
})
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}`
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
}
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
logger.warn('Failed to ensure subscription download directory:', error)
}
}
export class SubscriptionScheduler extends EventEmitter {
private timer?: NodeJS.Timeout
private checking = false
private pendingRun = false
private downloads: Map<string, TrackedDownload> = new Map()
constructor() {
super()
downloadEngine.on('download-completed', (id: string) => {
const tracked = this.downloads.get(id)
if (!tracked) {
return
}
this.downloads.delete(id)
subscriptionManager.update(tracked.subscriptionId, {
status: 'up-to-date',
lastSuccessAt: Date.now()
})
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
downloadId: id
})
})
downloadEngine.on('download-error', (id: string, error: Error) => {
const tracked = this.downloads.get(id)
if (!tracked) {
return
}
const currentRetries = tracked.retries ?? 0
if (currentRetries < 1) {
logger.warn('Retrying failed subscription download', { id, error })
this.queueDownload(
tracked.subscriptionId,
tracked.itemId,
tracked.url,
currentRetries + 1
).catch((queueError) => {
logger.error('Retry queue failed:', queueError)
})
return
}
this.downloads.delete(id)
subscriptionManager.update(tracked.subscriptionId, {
status: 'failed',
lastCheckedAt: Date.now()
})
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
downloadId: null
})
})
}
start(): void {
this.scheduleNextRun(0)
}
refreshInterval(): void {
if (this.timer) {
clearTimeout(this.timer)
}
this.scheduleNextRun()
}
async runNow(subscriptionId?: string): Promise<void> {
if (subscriptionId) {
const target = subscriptionManager.getById(subscriptionId)
if (target?.enabled) {
await this.checkSubscription(target)
}
return
}
await this.checkAll()
}
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
const subscription = subscriptionManager.getById(subscriptionId)
if (!subscription) {
return false
}
const item = subscription.items.find((entry) => entry.id === itemId)
if (!item || item.addedToQueue) {
return false
}
await this.queueDownload(subscriptionId, itemId, item.url)
return true
}
private scheduleNextRun(initialDelay?: number): void {
if (this.timer) {
clearTimeout(this.timer)
}
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
this.timer = setTimeout(() => {
void this.checkAll().finally(() => this.scheduleNextRun())
}, delayMs)
}
private async checkAll(): Promise<void> {
if (this.checking) {
this.pendingRun = true
return
}
this.checking = true
try {
const subscriptions = subscriptionManager
.getAll()
.filter((subscription) => subscription.enabled)
for (const subscription of subscriptions) {
await this.checkSubscription(subscription)
}
} catch (error) {
logger.error('Failed to run subscription sync', error)
} finally {
this.checking = false
if (this.pendingRun) {
this.pendingRun = false
void this.checkAll()
}
}
}
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
const startedAt = Date.now()
subscriptionManager.update(subscription.id, {
status: 'checking',
lastCheckedAt: startedAt,
lastError: undefined
})
try {
const feed = await parser.parseURL(subscription.feedUrl)
const feedItems = Array.isArray(feed.items) ? feed.items : []
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
const recentItems = this.filterRecentItems(subscription, normalizedItems)
const unseenItems = this.filterNewItems(subscription, recentItems)
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
const keywordFiltered =
keywords.length > 0
? unseenItems.filter((item) => {
const lowered = item.title.toLowerCase()
return keywords.some((keyword) => lowered.includes(keyword))
})
: unseenItems
const deduped = keywordFiltered
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
.sort((a, b) => b.publishedAt - a.publishedAt)
const itemsToDownload =
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
subscriptionManager.replaceFeedItems(
subscription.id,
this.buildFeedItems(normalizedItems, subscription)
)
if (itemsToDownload.length > 0) {
for (const item of itemsToDownload) {
await this.queueDownload(subscription.id, item.id, item.url)
}
}
const latestItem = normalizedItems[0]
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,
title:
typeof feed.title === 'string' && feed.title.trim().length > 0
? feed.title.trim()
: subscription.title,
sourceUrl:
typeof feed.link === 'string' && feed.link.trim().length > 0
? feed.link.trim()
: subscription.sourceUrl
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown RSS error'
subscriptionManager.update(subscription.id, {
status: 'failed',
lastError: message,
lastCheckedAt: Date.now()
})
logger.error('Subscription check failed:', { id: subscription.id, error })
}
}
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
const normalized: FeedItem[] = []
for (const item of items) {
const id = this.resolveItemId(item)
if (!id || !item.link || !item.title) {
continue
}
normalized.push({
id,
url: item.link,
title: item.title,
publishedAt: this.resolvePublishedAt(item),
thumbnail: this.resolveThumbnail(item)
})
}
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
}
private buildFeedItems(
items: FeedItem[],
subscription: SubscriptionRule
): SubscriptionFeedItem[] {
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
return items.map((item) => {
const tracked = this.getTrackedDownloadByUrl(item.url)
const existing = existingItems.get(item.id)
return {
id: item.id,
url: item.url,
title: item.title,
publishedAt: item.publishedAt,
thumbnail: item.thumbnail,
addedToQueue:
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
downloadId: tracked?.downloadId ?? existing?.downloadId
}
})
}
private resolveItemId(item: ParserItem): string | null {
const idCandidate =
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
if (!idCandidate) {
return null
}
return idCandidate.trim()
}
private resolvePublishedAt(item: ParserItem): number {
const candidates = [item.isoDate, item.pubDate]
for (const candidate of candidates) {
if (!candidate) {
continue
}
const timestamp = Date.parse(candidate)
if (!Number.isNaN(timestamp)) {
return timestamp
}
}
return Date.now()
}
private resolveThumbnail(item: ParserItem): string | undefined {
// Try media:thumbnail first
const thumbnail = item.mediaThumbnail
if (Array.isArray(thumbnail)) {
const found = thumbnail.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
return thumbnail.url as string | undefined
}
// Try enclosure (for RSS feeds with image/jpeg type)
const enclosure = item.enclosure
if (Array.isArray(enclosure)) {
const imageEnclosure = enclosure.find(
(entry) => entry?.url && entry?.type?.startsWith('image/')
)
if (imageEnclosure?.url) return imageEnclosure.url
}
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
const enc = enclosure as { url?: string; type?: string }
if (enc.url && enc.type?.startsWith('image/')) {
return enc.url
}
}
// Try media:content as fallback
const mediaContent = item.mediaContent
if (Array.isArray(mediaContent)) {
const found = mediaContent.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
return mediaContent.url as string | undefined
}
return undefined
}
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
if (lastKnownPublishedAt === 0) {
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
}
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
}
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
}
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
const seenIds = new Set(subscription.items.map((item) => item.id))
return items.filter((item) => !seenIds.has(item.id))
}
private async queueDownload(
subscriptionId: string,
itemId: string,
url: string,
retryCount = 0
): Promise<void> {
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
const isRetry = retryCount > 0
if (this.downloads.has(downloadId) && !isRetry) {
return
}
const subscription = subscriptionManager.getById(subscriptionId)
if (!subscription) {
return
}
const settings = settingsManager.getAll()
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
const namingTemplate =
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
ensureDirectoryExists(downloadDirectory)
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
try {
downloadEngine.startDownload(downloadId, {
url,
type: 'video',
customDownloadPath: downloadDirectory,
customFilenameTemplate: namingTemplate,
tags,
origin: 'subscription',
subscriptionId
})
this.downloads.set(downloadId, {
subscriptionId,
itemId,
url,
retries: retryCount,
downloadId
})
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
added: true,
downloadId
})
} catch (error) {
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
subscriptionManager.update(subscriptionId, {
status: 'failed'
})
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
added: false,
downloadId: null
})
}
}
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
for (const tracked of this.downloads.values()) {
if (tracked.url === url) {
return tracked
}
}
return undefined
}
}
export const subscriptionScheduler = new SubscriptionScheduler()

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app } from 'electron'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
@@ -37,7 +37,11 @@ export class ThumbnailCache {
return null
}
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
if (
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
originalUrl.startsWith('file://') ||
originalUrl.startsWith('data:')
) {
return originalUrl
}
@@ -59,7 +63,7 @@ export class ThumbnailCache {
const existingPath = await this.findExistingPath(basePath, defaultExtension)
if (existingPath) {
return pathToFileURL(existingPath).toString()
return this.toAppProtocolUrl(existingPath)
}
const response = await fetch(originalUrl)
@@ -75,7 +79,7 @@ export class ThumbnailCache {
const finalPath = `${basePath}${extension}`
await fsPromises.writeFile(finalPath, buffer)
return pathToFileURL(finalPath).toString()
return this.toAppProtocolUrl(finalPath)
} catch (error) {
console.error('Failed to cache thumbnail:', error)
return null
@@ -132,6 +136,13 @@ export class ThumbnailCache {
const basePath = path.join(cacheDir, `${hash}`)
return { basePath, defaultExtension: extension }
}
private toAppProtocolUrl(filePath: string): string {
const userDataPath = app.getPath('userData')
const relativePath = path.relative(userDataPath, filePath).replace(/\\/g, '/')
return `${APP_PROTOCOL_SCHEME}${relativePath}`
}
}
export const thumbnailCache = new ThumbnailCache()

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { AppSettings } from '../shared/types'
@@ -8,6 +9,24 @@ const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
const ensureDirectoryExists = (dir: string) => {
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
console.error('Failed to ensure download directory:', error)
}
}
const resolveDefaultDownloadPath = () => {
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
ensureDirectoryExists(downloadDir)
return downloadDir
}
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
class SettingsManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
@@ -16,9 +35,10 @@ class SettingsManager {
this.store = new Store({
defaults: {
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
downloadPath: DEFAULT_DOWNLOAD_PATH
}
})
this.ensureDownloadDirectory()
}
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
@@ -26,6 +46,9 @@ class SettingsManager {
}
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
if (key === 'downloadPath' && typeof value === 'string') {
ensureDirectoryExists(value)
}
this.store.set(key, value)
}
@@ -35,6 +58,9 @@ class SettingsManager {
setAll(settings: Partial<AppSettings>): void {
for (const [key, value] of Object.entries(settings)) {
if (key === 'downloadPath' && typeof value === 'string') {
ensureDirectoryExists(value)
}
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
}
}
@@ -43,8 +69,25 @@ class SettingsManager {
this.store.clear()
this.store.set({
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
downloadPath: DEFAULT_DOWNLOAD_PATH
})
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
}
private ensureDownloadDirectory(): void {
try {
const currentPath: string | undefined = this.store.get('downloadPath')
const normalizedDownloadPath =
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
? DEFAULT_DOWNLOAD_PATH
: currentPath
if (normalizedDownloadPath !== currentPath) {
this.store.set('downloadPath', normalizedDownloadPath)
}
ensureDirectoryExists(normalizedDownloadPath)
} catch (error) {
console.error('Failed to verify download directory:', error)
}
}
}

View File

@@ -1,5 +1,7 @@
import { normalizeLanguageCode } from '@shared/languages'
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
import appIcon from '../../resources/icon.png?asset'
import trayIcon from '../../resources/tray-icon.png?asset'
import { settingsManager } from './settings'
let tray: Tray | null = null
@@ -8,7 +10,7 @@ let tray: Tray | null = null
* Get translated text based on current language setting
*/
function t(key: 'showHome' | 'quit'): string {
const language = settingsManager.get('language') || 'en'
const language = normalizeLanguageCode(settingsManager.get('language'))
const translations = {
en: {
@@ -16,12 +18,12 @@ function t(key: 'showHome' | 'quit'): string {
quit: 'Quit'
},
zh: {
showHome: '打开首页',
quit: '关闭应用'
showHome: '显示主页',
quit: '退出应用'
}
}
const displayLang = language.startsWith('en') ? 'en' : 'zh'
const displayLang = language.startsWith('zh') ? 'zh' : 'en'
return translations[displayLang][key]
}
@@ -85,9 +87,17 @@ export function createTray(): void {
return
}
const trayIcon = nativeImage.createFromPath(appIcon)
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
const trayIconImage = nativeImage.createFromPath(iconPath)
tray = new Tray(trayIcon)
// For macOS, ensure the icon is properly sized
if (process.platform === 'darwin') {
// Resize to 16x16 for macOS tray
trayIconImage.setTemplateImage(true)
}
tray = new Tray(trayIconImage)
// Set tooltip
tray.setToolTip('VidBee')

View File

@@ -0,0 +1,38 @@
import { app } from 'electron'
import log from 'electron-log/main'
const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32'])
export function isAutoLaunchSupported(): boolean {
return SUPPORTED_PLATFORMS.has(process.platform)
}
export function applyAutoLaunchSetting(enabled: boolean): void {
if (!isAutoLaunchSupported()) {
log.info('Auto launch is not supported on this platform, skipping setting update')
return
}
const updateSetting = () => {
try {
const options: Parameters<typeof app.setLoginItemSettings>[0] = {
openAtLogin: enabled
}
if (process.platform === 'darwin') {
options.openAsHidden = true
}
app.setLoginItemSettings(options)
log.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)
} catch (error) {
log.error('Failed to update login item settings:', error)
}
}
if (app.isReady()) {
updateSetting()
} else {
app.once('ready', updateSetting)
}
}

16
src/main/utils/dock.ts Normal file
View File

@@ -0,0 +1,16 @@
import { app } from 'electron'
/**
* Apply Dock visibility preference on macOS.
*/
export function applyDockVisibility(hideDockIcon: boolean): void {
if (process.platform !== 'darwin' || !app.dock) {
return
}
if (hideDockIcon) {
app.dock.hide()
} else {
app.dock.show()
}
}

25
src/main/utils/logger.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Main process logger utility
* Directly use electron-log/main
*/
import log from 'electron-log/main'
// Export electron-log instance
export default log
// Export commonly used logging methods
export const logger = log
// Predefined scoped loggers
export const scopedLoggers = {
main: log.scope('main'),
ipc: log.scope('ipc'),
window: log.scope('window'),
download: log.scope('download'),
engine: log.scope('engine'),
system: log.scope('system'),
storage: log.scope('storage'),
thumbnail: log.scope('thumbnail'),
history: log.scope('history')
}

View File

@@ -0,0 +1,19 @@
import os from 'node:os'
import path from 'node:path'
export const resolvePathWithHome = (rawPath?: string | null): string | undefined => {
const trimmed = rawPath?.trim()
if (!trimmed) {
return undefined
}
if (trimmed === '~') {
return os.homedir()
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2))
}
return trimmed
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<title>VidBee</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
</head>
<body>

View File

@@ -2,54 +2,264 @@ 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'
import type { SubscriptionRule } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
import { 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 { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About'
import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
import { Subscriptions } from './pages/Subscriptions'
import { SupportedSites } from './pages/SupportedSites'
import { loadSettingsAtom, settingsAtom } from './store/settings'
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
type Page = 'home' | 'settings' | 'about' | 'sites'
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
const pageToPath: Record<Page, string> = {
home: '/',
subscriptions: '/subscriptions',
settings: '/settings',
about: '/about',
sites: '/sites'
}
const normalizePathname = (pathname: string): string => {
const trimmed = pathname.replace(/\/+$/, '')
return trimmed === '' ? '/' : trimmed
}
const pathToPage = (pathname: string): Page => {
const normalized = normalizePathname(pathname)
switch (normalized) {
case '/subscriptions':
return 'subscriptions'
case '/settings':
return 'settings'
case '/about':
return 'about'
case '/sites':
return 'sites'
default:
return 'home'
}
}
function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const [platform, setPlatform] = useState<string>('')
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
const [settings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
const navigate = useNavigate()
const location = useLocation()
const currentPage = pathToPage(location.pathname)
const renderPage = () => {
switch (currentPage) {
case 'home':
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
case 'settings':
return <Settings />
case 'about':
return <About />
case 'sites':
return <SupportedSites />
default:
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
const handlePageChange = (page: Page) => {
const targetPath = pageToPath[page] ?? '/'
if (normalizePathname(location.pathname) !== targetPath) {
navigate(targetPath)
}
}
useEffect(() => {
loadSettings()
}, [loadSettings])
useEffect(() => {
loadSubscriptions()
const handleSubscriptions = (...args: unknown[]) => {
const list = args[0]
if (Array.isArray(list)) {
setSubscriptions(list as SubscriptionRule[])
}
}
ipcEvents.on('subscriptions:updated', handleSubscriptions)
return () => {
ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)
}
}, [loadSubscriptions, setSubscriptions])
// Load or remove analytics script based on settings
useEffect(() => {
const scriptId = 'analytics-script'
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
if (settings.enableAnalytics) {
// Remove existing script if it exists
if (existingScript) {
existingScript.remove()
}
// Create and append new script
const script = document.createElement('script')
script.id = scriptId
script.src = 'https://rybbit.102417.xyz/api/script.js'
script.setAttribute('data-site-id', '7bc6f6d625a4')
script.defer = true
script.async = true
document.head.appendChild(script)
analyticsScriptRef.current = script
} else {
// Remove script if analytics is disabled
if (existingScript) {
existingScript.remove()
analyticsScriptRef.current = null
}
}
return () => {
// Cleanup on unmount
const script = document.getElementById(scriptId)
if (script) {
script.remove()
}
}
}, [settings.enableAnalytics])
useEffect(() => {
// Get platform info to determine if we should show title bar
const getPlatform = async () => {
try {
const platformInfo = await ipcServices.app.getPlatform()
setPlatform(platformInfo)
} catch (error) {
console.error('Failed to get platform info:', error)
// Default to showing title bar if platform detection fails
setPlatform('unknown')
}
}
getPlatform()
}, [])
useEffect(() => {
if (!window?.api) {
return
}
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState()
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.success(downloadedMessage)
showRestartPrompt()
}
const handleUpdateError = (rawMessage: unknown) => {
const message = typeof rawMessage === 'string' ? rawMessage : ''
resetDownloadState()
const errorMessage = message || t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: errorMessage }))
}
const handleDownloadProgress = (rawProgress: unknown) => {
const progress = (rawProgress ?? {}) as { percent?: number }
if (typeof progress?.percent === 'number') {
console.info('Update download progress:', progress.percent.toFixed(2))
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
const versionLabel = payload.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
// Only listen to update events that should be shown globally
// update:available is handled in About page only
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:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [t])
return (
<div className="flex flex-row h-screen">
{/* Sidebar Navigation */}
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
{/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
{/* Custom Title Bar */}
<TitleBar />
<TitleBar platform={platform} />
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
style={{ maxWidth: '100%' }}
>
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
{renderPage()}
<Routes>
<Route
path="/"
element={
<Home
onOpenSupportedSites={() => handlePageChange('sites')}
onOpenSettings={() => handlePageChange('settings')}
/>
}
/>
<Route path="/subscriptions" element={<Subscriptions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/about" element={<About />} />
<Route path="/sites" element={<SupportedSites />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</ScrollArea>
</main>
<Toaster />
<Toaster richColors={true} />
</div>
)
}
@@ -57,7 +267,9 @@ function AppContent() {
function App() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AppContent />
<HashRouter>
<AppContent />
</HashRouter>
</ThemeProvider>
)
}

View File

@@ -1,5 +1,7 @@
@import 'tailwindcss';
@import './theme.css';
@import "tw-animate-css";
@layer base {
* {
@@ -8,5 +10,9 @@
body {
@apply text-foreground;
}
input::placeholder,
textarea::placeholder {
opacity: 0.4;
}
}

View File

@@ -60,32 +60,32 @@
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary: oklch(0.8223 0.1704 79.8747);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted: oklch(0.3485 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--accent-foreground: oklch(0.8223 0.1704 79.8747);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--ring: oklch(0.8223 0.1704 79.8747);
--chart-1: oklch(0.8223 0.1704 79.8747);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;

View File

@@ -1,33 +1,162 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Progress } from '@renderer/components/ui/progress'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useSetAtom } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
ExternalLink,
ChevronDown,
ChevronUp,
Copy,
FolderOpen,
Loader2,
Play,
Trash2,
X
} from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ipcServices } from '../../lib/ipc'
import {
type DownloadRecord,
removeDownloadAtom,
removeHistoryRecordAtom
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
const normalizeSavedFileName = (fileName?: string): string | undefined => {
if (!fileName) {
return undefined
}
const trimmed = fileName.trim()
if (!trimmed) {
return undefined
}
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
}
const generateFilePathCandidates = (
downloadPath: string,
title: string,
format: string,
savedFileName?: string
): string[] => {
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
const safeTitle = title.trim() || 'Unknown'
const savedNameCandidates: string[] = []
const trimmedSavedFileName = savedFileName?.trim()
if (trimmedSavedFileName) {
const normalized = normalizeSavedFileName(trimmedSavedFileName)
if (normalized) {
savedNameCandidates.push(normalized)
}
if (!normalized || normalized !== trimmedSavedFileName) {
savedNameCandidates.push(trimmedSavedFileName)
}
}
const candidateFileNames =
savedNameCandidates.length > 0
? savedNameCandidates
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
return Array.from(
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
)
}
const tryFileOperation = async (
paths: string[],
operation: (filePath: string) => Promise<boolean>
): Promise<boolean> => {
for (const filePath of paths) {
const success = await operation(filePath)
if (success) {
return true
}
}
return false
}
const getSavedFileExtension = (fileName?: string): string | undefined => {
const normalized = normalizeSavedFileName(fileName)
if (!normalized) {
return undefined
}
if (!normalized.includes('.')) {
return undefined
}
const ext = normalized.split('.').pop()
return ext?.toLowerCase()
}
const resolveDownloadExtension = (download: DownloadRecord): string => {
const savedExt = getSavedFileExtension(download.savedFileName)
if (savedExt) {
return savedExt
}
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
if (selectedExt) {
return selectedExt
}
return download.type === 'audio' ? 'mp3' : 'mp4'
}
const getFormatLabel = (download: DownloadRecord): string | undefined => {
if (download.selectedFormat?.ext) {
return download.selectedFormat.ext.toUpperCase()
}
const savedExt = getSavedFileExtension(download.savedFileName)
return savedExt ? savedExt.toUpperCase() : undefined
}
const getQualityLabel = (download: DownloadRecord): string | undefined => {
const format = download.selectedFormat
if (!format) {
return undefined
}
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 undefined
}
const sanitizeCodec = (codec?: string | null): string | undefined => {
if (!codec || codec === 'none') {
return undefined
}
return codec
}
const getCodecLabel = (download: DownloadRecord): string | undefined => {
const format = download.selectedFormat
if (!format) {
return undefined
}
if (download.type === 'audio' || download.type === 'extract') {
return sanitizeCodec(format.acodec)
}
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
}
interface DownloadItemProps {
download: DownloadRecord
}
type MetadataDetail = {
label: string
value: ReactNode
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
@@ -51,19 +180,72 @@ const formatDate = (timestamp?: number) => {
return new Date(timestamp).toLocaleString()
}
const formatDateShort = (timestamp?: number) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export function DownloadItem({ download }: DownloadItemProps) {
const { t } = useTranslation()
const settings = useAtomValue(settingsAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const removeHistory = useSetAtom(removeHistoryRecordAtom)
const isHistory = download.entryType === 'history'
const isSubscriptionDownload = download.origin === 'subscription'
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
const actionsContainerClass = showActionsWithoutHover
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
const resolvedExtension = resolveDownloadExtension(download)
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
const [detailsOpen, setDetailsOpen] = useState(false)
// Check if file exists when download data changes
useEffect(() => {
const checkFileExists = async () => {
if (!download.title || !download.downloadPath) {
setFileExists(false)
return
}
try {
const formatForPath = resolvedExtension
const filePaths = generateFilePathCandidates(
download.downloadPath,
download.title,
formatForPath,
download.savedFileName
)
for (const filePath of filePaths) {
const exists = await ipcServices.fs.fileExists(filePath)
if (exists) {
setFileExists(true)
return
}
}
setFileExists(false)
} catch (error) {
console.error('Failed to check file existence:', error)
setFileExists(false)
}
}
checkFileExists()
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
const handleCancel = async () => {
if (isHistory) return
@@ -75,14 +257,20 @@ export function DownloadItem({ download }: DownloadItemProps) {
}
}
const handleOpenFileLocation = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFolderFailed'))
return
}
const handleOpenFolder = async () => {
try {
const success = await ipcServices.fs.openFileLocation(download.outputPath)
const downloadPath = download.downloadPath || settings.downloadPath
const format = resolvedExtension
const filePaths = generateFilePathCandidates(
downloadPath,
download.title,
format,
download.savedFileName
)
const success = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.openFileLocation(filePath)
)
if (!success) {
toast.error(t('notifications.openFolderFailed'))
}
@@ -91,29 +279,75 @@ export function DownloadItem({ download }: DownloadItemProps) {
toast.error(t('notifications.openFolderFailed'))
}
}
// Check if copy to clipboard is available
const canCopyToClipboard = () => {
return Boolean(download.title && download.downloadPath && fileExists)
}
const handleOpenFile = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFileFailed'))
// need title, downloadPath, format
const handleCopyToClipboard = async () => {
if (!canCopyToClipboard()) {
toast.error(t('notifications.copyFailed'))
return
}
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
const downloadPath = download.downloadPath
const format = resolvedExtension
const title = download.title
if (!downloadPath || !title) {
toast.error(t('notifications.copyFailed'))
return
}
try {
await ipcServices.fs.openFileLocation(download.outputPath)
// Generate file path using downloadPath + title + ext
const filePaths = generateFilePathCandidates(
downloadPath,
title,
format,
download.savedFileName
)
const success = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.copyFileToClipboard(filePath)
)
if (!success) {
toast.error(t('notifications.copyFailed'))
return
}
toast.success(t('notifications.videoCopied'))
} catch (error) {
console.error('Failed to open file:', error)
toast.error(t('notifications.openFileFailed'))
console.error('Failed to copy file to clipboard:', error)
toast.error(t('notifications.copyFailed'))
}
}
const handleOpenFolder = async () => {
await handleOpenFileLocation()
}
// need id
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
const downloadPath = download.downloadPath || settings.downloadPath
const format = resolvedExtension
const filePaths = generateFilePathCandidates(
downloadPath,
download.title,
format,
download.savedFileName
)
// Remove from history first
await ipcServices.history.removeHistoryItem(download.id)
// Then try to delete the file
const deleted = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.deleteFile(filePath)
)
if (!deleted) {
console.warn('Failed to delete download file for history item:', download.id)
}
removeHistory(download.id)
toast.success(t('notifications.itemRemoved'))
} catch (error) {
@@ -132,7 +366,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
case 'processing':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'pending':
return <Loader2 className="h-4 w-4 text-muted-foreground" />
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
case 'cancelled':
return <X className="h-4 w-4 text-muted-foreground" />
default:
@@ -161,16 +395,258 @@ export function DownloadItem({ download }: DownloadItemProps) {
const statusIcon = getStatusIcon()
const statusText = getStatusText()
const sourceDisplay =
download.uploader && download.channel && download.uploader !== download.channel
? `${download.uploader}${download.channel}`
: download.uploader || download.channel || ''
const metadataDetails: MetadataDetail[] = []
if (timestamp) {
metadataDetails.push({
label: t('history.date'),
value: formatDate(timestamp)
})
}
if (sourceDisplay) {
metadataDetails.push({
label: t('download.metadata.source'),
value: sourceDisplay
})
}
if (download.playlistId) {
metadataDetails.push({
label: t('download.metadata.playlist'),
value: (
<span>
{download.playlistTitle || t('playlist.untitled')}
{download.playlistIndex !== undefined && download.playlistSize !== undefined ? (
<span className="text-muted-foreground/80">
{` ${t('playlist.positionLabel', {
index: download.playlistIndex,
total: download.playlistSize
})}`}
</span>
) : null}
</span>
)
})
}
if (download.duration) {
metadataDetails.push({
label: t('history.duration'),
value: formatDuration(download.duration)
})
}
const selectedFormatSize =
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
const formatLabelValue = getFormatLabel(download)
if (formatLabelValue) {
metadataDetails.push({
label: t('download.metadata.format'),
value: formatLabelValue
})
}
const qualityLabel = getQualityLabel(download)
if (qualityLabel) {
metadataDetails.push({
label: t('download.metadata.quality'),
value: qualityLabel
})
}
if (inlineFileSize) {
metadataDetails.push({
label: t('history.fileSize'),
value: inlineFileSize
})
}
const codecValue = getCodecLabel(download)
if (codecValue) {
metadataDetails.push({
label: t('download.metadata.codec'),
value: codecValue
})
}
if (normalizedSavedFileName || download.savedFileName) {
metadataDetails.push({
label: t('download.metadata.savedFile'),
value: normalizedSavedFileName ?? download.savedFileName
})
}
if (download.url) {
metadataDetails.push({
label: t('download.metadata.url'),
value: (
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="wrap-break-word text-primary hover:underline"
>
{download.url}
</a>
)
})
}
// Additional metadata fields
if (download.description) {
metadataDetails.push({
label: t('download.metadata.description'),
value: <span className="wrap-break-word">{download.description}</span>
})
}
if (download.viewCount !== undefined && download.viewCount !== null) {
metadataDetails.push({
label: t('download.metadata.views'),
value: download.viewCount.toLocaleString()
})
}
if (download.tags && download.tags.length > 0) {
metadataDetails.push({
label: t('download.metadata.tags'),
value: (
<div className="flex flex-wrap gap-1">
{download.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0.5">
{tag}
</Badge>
))}
</div>
)
})
}
if (download.downloadPath) {
metadataDetails.push({
label: t('download.metadata.downloadPath'),
value: <span className="wrap-break-word font-mono text-xs">{download.downloadPath}</span>
})
}
// Timestamps
if (download.createdAt && download.createdAt !== timestamp) {
metadataDetails.push({
label: t('download.metadata.createdAt'),
value: formatDate(download.createdAt)
})
}
if (download.startedAt) {
metadataDetails.push({
label: t('download.metadata.startedAt'),
value: formatDate(download.startedAt)
})
}
if (download.completedAt && download.completedAt !== timestamp) {
metadataDetails.push({
label: t('download.metadata.completedAt'),
value: formatDate(download.completedAt)
})
}
// Speed
if (download.speed) {
metadataDetails.push({
label: t('download.metadata.speed'),
value: download.speed
})
}
// File size (if different from inlineFileSize)
if (download.fileSize && download.fileSize !== selectedFormatSize) {
metadataDetails.push({
label: t('download.metadata.fileSize'),
value: formatFileSize(download.fileSize)
})
}
// Selected format details
if (download.selectedFormat) {
if (download.selectedFormat.width) {
metadataDetails.push({
label: t('download.metadata.width'),
value: `${download.selectedFormat.width}px`
})
}
if (download.selectedFormat.height && !qualityLabel) {
metadataDetails.push({
label: t('download.metadata.height'),
value: `${download.selectedFormat.height}px`
})
}
if (download.selectedFormat.fps) {
metadataDetails.push({
label: t('download.metadata.fps'),
value: `${download.selectedFormat.fps}`
})
}
if (download.selectedFormat.vcodec) {
metadataDetails.push({
label: t('download.metadata.videoCodec'),
value: download.selectedFormat.vcodec
})
}
if (download.selectedFormat.acodec) {
metadataDetails.push({
label: t('download.metadata.audioCodec'),
value: download.selectedFormat.acodec
})
}
if (download.selectedFormat.format_note) {
metadataDetails.push({
label: t('download.metadata.formatNote'),
value: download.selectedFormat.format_note
})
}
if (download.selectedFormat.protocol) {
metadataDetails.push({
label: t('download.metadata.protocol'),
value: download.selectedFormat.protocol.toUpperCase()
})
}
}
if (isSubscriptionDownload) {
metadataDetails.push({
label: t('download.metadata.subscription'),
value: subscriptionLabel
})
}
const hasMetadataDetails = metadataDetails.length > 0
return (
<div className="group relative w-full max-w-full overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
{/* Thumbnail */}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60">
<ImageWithPlaceholder
src={thumbnailSrc}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
<RemoteImage
src={download.thumbnail}
alt={download.title}
className="w-32 h-18 object-cover aspect-video"
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
@@ -179,105 +655,116 @@ export function DownloadItem({ download }: DownloadItemProps) {
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs wrap-break-word">{download.title}</p>
</TooltipContent>
</Tooltip>
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
{isSubscriptionDownload && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('subscriptions.labels.subscription')}
</Badge>
)}
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
{download.type}
</Badge>
{(statusIcon || statusText) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcon}
<span>{statusText}</span>
</div>
)}
</div>
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
<span className="truncate text-muted-foreground/80">
{formatDateShort(timestamp)}
</span>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
{/* Source link */}
{(sourceDisplay || download.url) &&
(download.url ? (
<Tooltip>
<TooltipTrigger asChild>
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="max-w-[180px] truncate hover:text-primary transition-colors"
>
{sourceDisplay || download.url}
</a>
</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-word">
<p>{download.url}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="truncate">{sourceDisplay}</span>
))}
<span className="truncate max-w-[120px] text-left">
<Tooltip>
<TooltipTrigger asChild>
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors cursor-pointer"
>
{download.uploader &&
download.channel &&
download.uploader !== download.channel
? `${download.uploader}${download.channel}`
: download.uploader
? `${download.uploader}`
: download.channel
? `${download.channel}`
: ''}
</a>
</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-word">
<p>{download.url}</p>
</TooltipContent>
</Tooltip>
</span>
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
{download.selectedFormat ? (
{/* Playlist info */}
{download.playlistId && (
<>
{download.selectedFormat.height ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.height}p
{download.selectedFormat.fps === 60 ? '60' : ''}
</Badge>
) : null}
{download.selectedFormat.ext ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.ext.toUpperCase()}
</Badge>
) : null}
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
<span className="text-[10px] opacity-75">
{formatFileSize(
download.selectedFormat.filesize ||
download.selectedFormat.filesize_approx
)}
</span>
) : null}
</>
) : (
<>
{download.quality ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.quality}
</Badge>
) : null}
{download.format ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.format.toUpperCase()}
</Badge>
) : null}
{download.codec ? (
<span className="text-[10px] opacity-75">{download.codec}</span>
) : null}
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('playlist.badgeLabel')}
</Badge>
<span className="max-w-[200px] truncate">
{download.playlistTitle || t('playlist.untitled')}
{download.playlistIndex !== undefined &&
download.playlistSize !== undefined &&
` (${download.playlistIndex}/${download.playlistSize})`}
</span>
</>
)}
{/* Quality badge */}
{qualityLabel && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
{qualityLabel}
</Badge>
)}
{/* File size */}
{inlineFileSize && <span>{inlineFileSize}</span>}
{/* Details toggle */}
{hasMetadataDetails && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={detailsOpen ? 'default' : 'ghost'}
size="icon"
className="h-6 w-6 shrink-0"
type="button"
onClick={() => setDetailsOpen((prev) => !prev)}
>
{detailsOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{detailsOpen && hasMetadataDetails && (
<div className="space-y-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-3 text-xs">
{metadataDetails.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex gap-3">
<span className="w-24 shrink-0 text-muted-foreground">{item.label}</span>
<span className="flex-1 wrap-break-word text-foreground">{item.value}</span>
</div>
))}
</div>
)}
</div>
<div className={actionsContainerClass}>
{isHistory ? (
<>
{download.outputPath && (
{download.status === 'completed' && (
<>
<Tooltip>
<TooltipTrigger asChild>
@@ -285,13 +772,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
onClick={handleCopyToClipboard}
disabled={!canCopyToClipboard()}
>
<ExternalLink className="h-4 w-4" />
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -329,7 +817,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
</>
) : (
<>
{download.status === 'completed' && download.outputPath && (
{download.status === 'completed' && (
<>
<Tooltip>
<TooltipTrigger asChild>
@@ -337,13 +825,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
onClick={handleCopyToClipboard}
disabled={!canCopyToClipboard()}
>
<ExternalLink className="h-4 w-4" />
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>

View File

@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next'
import type { DownloadRecord } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
interface PlaylistDownloadGroupProps {
groupId: string
title: string
records: DownloadRecord[]
totalCount: number
}
export function PlaylistDownloadGroup({
groupId,
title,
records,
totalCount
}: PlaylistDownloadGroupProps) {
const { t } = useTranslation()
const completedCount = records.filter((record) => record.status === 'completed').length
const errorCount = records.filter((record) => record.status === 'error').length
const activeCount = records.filter((record) =>
['downloading', 'processing', 'pending'].includes(record.status)
).length
const displayTitle = title || t('playlist.untitled')
return (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
{errorCount > 0 && (
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
</div>
</div>
<div className="space-y-2">
{records.map((record) => (
<div
key={`${groupId}:${record.entryType}:${record.id}`}
className="border-l border-border/50 pl-3"
>
<DownloadItem download={record} />
</div>
))}
</div>
</div>
)
}

View File

@@ -1,12 +1,15 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { useAtomValue, useSetAtom } from 'jotai'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import { cn } from '@renderer/lib/utils'
import { useAtomValue } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import type { DownloadRecord } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
@@ -14,7 +17,6 @@ export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearCompleted = useSetAtom(clearCompletedAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
useHistorySync()
@@ -46,33 +48,59 @@ export function UnifiedDownloadHistory() {
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
const groupedView = useMemo(() => {
const groups = new Map<
string,
{ id: string; title: string; totalCount: number; records: DownloadRecord[] }
>()
const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =
[]
for (const record of filteredRecords) {
if (record.playlistId) {
let group = groups.get(record.playlistId)
if (!group) {
group = {
id: record.playlistId,
title: record.playlistTitle || record.title,
totalCount: record.playlistSize || 0,
records: []
}
groups.set(record.playlistId, group)
order.push({ type: 'group', id: record.playlistId })
}
group.records.push(record)
if (!group.title && record.playlistTitle) {
group.title = record.playlistTitle
}
if (!group.totalCount && record.playlistSize) {
group.totalCount = record.playlistSize
}
} else {
order.push({ type: 'single', record })
}
}
for (const group of groups.values()) {
group.records.sort((a, b) => {
const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER
const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER
if (aIndex !== bIndex) {
return aIndex - bIndex
}
return b.createdAt - a.createdAt
})
if (!group.totalCount) {
group.totalCount = group.records.length
}
}
return { order, groups }
}, [filteredRecords])
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="space-y-4">
<CardHeader className="gap-4 p-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
@@ -89,26 +117,55 @@ export function UnifiedDownloadHistory() {
onClick={() => setStatusFilter(filter.key)}
>
<span>{filter.label}</span>
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
<span
className={cn(
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
)}
>
{filter.count}
</span>
</Button>
)
})}
</div>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden w-full">
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg 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-2 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => (
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
))}
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
{groupedView.order.map((item) => {
if (item.type === 'single') {
return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
/>
)
}
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}
/>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,94 @@
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 type { PlaylistEntry, PlaylistInfo } from '@shared/types'
import { useTranslation } from 'react-i18next'
interface PlaylistPreviewCardProps {
playlist: PlaylistInfo
entries: PlaylistEntry[]
onClear?: () => void
}
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
const { t } = useTranslation()
const totalCount = playlist.entryCount
const selectedCount = entries.length
const firstIndex = entries[0]?.index ?? null
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
return (
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1 space-y-1">
<CardTitle
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
title={playlist.title}
>
{playlist.title || t('playlist.untitled')}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
{firstIndex !== null && lastIndex !== null ? (
<span className="truncate">
{t('playlist.selectedRange', {
start: firstIndex,
end: lastIndex
})}
</span>
) : (
<span className="truncate">{t('playlist.noRangeSelected')}</span>
)}
<span className="truncate">
{t('playlist.showingCount', { count: selectedCount })}
</span>
</div>
</CardDescription>
</div>
{onClear && (
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
{t('playlist.clearPreview')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/20">
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
{entries.length === 0 ? (
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
{t('playlist.noEntriesInRange')}
</li>
) : (
entries.map((entry) => (
<li
key={`${entry.index}-${entry.id}`}
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
>
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
#{entry.index}
</span>
<span
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
title={entry.title}
>
{entry.title}
</span>
</li>
))
)}
</ol>
</ScrollArea>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,308 @@
import { Button } from '@renderer/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Switch } from '@renderer/components/ui/switch'
import { ipcServices } from '@renderer/lib/ipc'
import { settingsAtom } from '@renderer/store/settings'
import { resolveFeedAtom } from '@renderer/store/subscriptions'
import type { SubscriptionRule } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { ChevronRight } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const sanitizeCommaList = (value: string) =>
value
.split(',')
.map((entry) => entry.trim())
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
export interface SubscriptionFormData {
url?: string
keywords?: string[]
tags?: string[]
onlyDownloadLatest?: boolean
downloadDirectory?: string
namingTemplate?: string
enabled?: boolean
}
interface SubscriptionFormDialogProps {
mode: 'add' | 'edit'
subscription?: SubscriptionRule
open: boolean
onSave: (data: SubscriptionFormData) => Promise<void>
onClose: () => void
}
export function SubscriptionFormDialog({
mode,
subscription,
open,
onSave,
onClose
}: SubscriptionFormDialogProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const resolveFeed = useSetAtom(resolveFeedAtom)
// Form state
const [url, setUrl] = useState('')
const [keywords, setKeywords] = useState('')
const [tags, setTags] = useState('')
const [onlyLatest, setOnlyLatest] = useState(false)
const [downloadDirectory, setDownloadDirectory] = useState('')
const [namingTemplate, setNamingTemplate] = useState('')
// Feed detection state
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
const prevDefaultPathRef = useRef(settings.downloadPath)
const urlInputId = useId()
// Initialize form values based on mode
useEffect(() => {
if (!open) {
return
}
if (mode === 'edit' && subscription) {
setUrl(subscription.feedUrl)
setKeywords(subscription.keywords.join(', '))
setTags(subscription.tags.join(', '))
setOnlyLatest(subscription.onlyDownloadLatest)
setDownloadDirectory(subscription.downloadDirectory || '')
setNamingTemplate(subscription.namingTemplate || '')
} else {
// Add mode - use defaults from settings
setUrl('')
setKeywords('')
setTags('')
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
setDownloadDirectory(settings.downloadPath)
setNamingTemplate(settings.subscriptionFilenameTemplate)
}
}, [
open,
mode,
subscription,
settings.subscriptionOnlyLatestDefault,
settings.downloadPath,
settings.subscriptionFilenameTemplate
])
// Sync download directory with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
const newPath = settings.downloadPath
setDownloadDirectory((prev) => {
if (!prev || prev === prevDefaultPathRef.current) {
return newPath
}
return prev
})
prevDefaultPathRef.current = newPath
}
}, [settings.downloadPath, mode])
// Sync naming template with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
setNamingTemplate(settings.subscriptionFilenameTemplate)
}
}, [settings.subscriptionFilenameTemplate, mode])
// Sync onlyLatest with settings changes (only in add mode)
useEffect(() => {
if (mode === 'add') {
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
}
}, [settings.subscriptionOnlyLatestDefault, mode])
// Feed detection logic
useEffect(() => {
if (!url.trim()) {
return
}
// In edit mode, don't detect if URL hasn't changed
if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {
return
}
if (detectTimeout.current) {
clearTimeout(detectTimeout.current)
}
detectTimeout.current = setTimeout(async () => {
setDetectingFeed(true)
try {
await resolveFeed(url.trim())
} catch (error) {
console.error('Failed to resolve feed:', error)
} finally {
setDetectingFeed(false)
}
}, 500)
return () => {
if (detectTimeout.current) {
clearTimeout(detectTimeout.current)
}
}
}, [url, resolveFeed, mode, subscription])
const handleSelectDirectory = async () => {
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
setDownloadDirectory(path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error(t('subscriptions.notifications.directoryError'))
}
}
const handleOpenRSSHubDocs = async () => {
try {
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
} catch (error) {
console.error('Failed to open RSSHub documentation:', error)
toast.error(t('subscriptions.notifications.openLinkError'))
}
}
const handleSave = async () => {
// Validate URL for add mode
if (mode === 'add' && !url.trim()) {
toast.error(t('subscriptions.notifications.missingUrl'))
return
}
const formData: SubscriptionFormData = {
keywords: sanitizeCommaList(keywords),
tags: sanitizeCommaList(tags),
onlyDownloadLatest: onlyLatest,
downloadDirectory: downloadDirectory || undefined,
namingTemplate: namingTemplate || undefined
}
// Include URL if it's provided and different from current (for edit mode)
if (url.trim()) {
if (
mode === 'add' ||
(mode === 'edit' && subscription && url.trim() !== subscription.feedUrl)
) {
try {
await resolveFeed(url.trim())
formData.url = url.trim()
} catch (error) {
console.error('Failed to resolve feed:', error)
toast.error(t('subscriptions.notifications.resolveError'))
return
}
}
}
await onSave(formData)
}
const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'
const descriptionKey =
mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'
const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'edit' && subscription
? t(titleKey, { name: subscription.title })
: t(titleKey)}
</DialogTitle>
<DialogDescription>{t(descriptionKey)}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
<Input
id={urlInputId}
value={url}
placeholder={t('subscriptions.placeholders.url')}
onChange={(event) => setUrl(event.target.value)}
/>
{detectingFeed && (
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
)}
{mode === 'add' && !url.trim() && (
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
<p className="text-xs text-muted-foreground flex-1">
{t('subscriptions.rssHub.hint')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenRSSHubDocs()}
className="h-5 w-5 p-0 shrink-0"
title={t('subscriptions.rssHub.openDocs')}
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
)}
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.customDirectory')}</Label>
<div className="flex gap-2">
<Input value={downloadDirectory} readOnly />
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
{t('subscriptions.actions.selectDirectory')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</div>
<DialogFooter>
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,221 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { cn } from '@renderer/lib/utils'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
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 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
}

View File

@@ -1,101 +1,126 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@renderer/lib/utils'
import { X } from 'lucide-react'
import * as React from 'react'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
const Dialog = DialogPrimitive.Root
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
const DialogTrigger = DialogPrimitive.Trigger
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
const DialogPortal = DialogPrimitive.Portal
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
const DialogClose = DialogPrimitive.Close
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
/>
)
}
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogDescription
DialogTrigger
}

View File

@@ -0,0 +1,35 @@
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-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-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -9,6 +9,7 @@ interface ImageWithPlaceholderProps {
placeholderClassName?: string
fallbackIcon?: React.ReactNode
onError?: () => void
onLoad?: () => void
}
export function ImageWithPlaceholder({
@@ -17,7 +18,8 @@ export function ImageWithPlaceholder({
className,
placeholderClassName,
fallbackIcon,
onError
onError,
onLoad
}: ImageWithPlaceholderProps) {
const [hasError, setHasError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
@@ -30,6 +32,7 @@ export function ImageWithPlaceholder({
const handleLoad = () => {
setIsLoading(false)
onLoad?.()
}
// Show placeholder if no src, error occurred, or still loading
@@ -44,7 +47,7 @@ export function ImageWithPlaceholder({
}
return (
<div className={cn('relative', className)}>
<div className={cn('relative w-full h-full', className)}>
{isLoading && (
<div
className={cn(

View File

@@ -0,0 +1,123 @@
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ImageWithPlaceholder } from './image-with-placeholder'
interface RemoteImageProps {
/**
* Remote image URL or local file path
* If it's a remote URL (http/https), it will be cached automatically
* If it's a local path (vidbee://, file:// or data:), it will be used directly
*/
src?: string | null
alt: string
className?: string
placeholderClassName?: string
fallbackIcon?: React.ReactNode
/**
* Custom loading icon/indicator
* @default Loader2 spinner
*/
loadingIcon?: React.ReactNode
onError?: () => void
/**
* Callback when loading state changes
*/
onLoadingChange?: (loading: boolean) => void
/**
* Whether to use thumbnail cache for remote URLs
* @default true
*/
useCache?: boolean
}
/**
* Unified remote image component with automatic caching support
*
* This component automatically handles:
* - Remote URL caching via thumbnail cache service
* - Local file paths (vidbee://, file://, data:)
* - Loading states and error handling
* - Placeholder display with loading indicator
*
* @example
* ```tsx
* // Remote URL with automatic caching
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
*
* // Local file path (no caching)
* <RemoteImage src="vidbee://thumbnails/example.jpg" alt="Local" />
*
* // Without cache (direct load)
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
*
* // Custom loading icon
* <RemoteImage
* src="https://example.com/image.jpg"
* alt="Example"
* loadingIcon={<CustomSpinner />}
* />
*
* // Track loading state
* <RemoteImage
* src="https://example.com/image.jpg"
* alt="Example"
* onLoadingChange={(loading) => console.log('Loading:', loading)}
* />
* ```
*/
export function RemoteImage({
src,
alt,
className,
placeholderClassName,
fallbackIcon,
loadingIcon,
onError,
onLoadingChange,
useCache = true
}: RemoteImageProps) {
const shouldUseCache =
useCache &&
src &&
!src.startsWith(APP_PROTOCOL_SCHEME) &&
!src.startsWith('file://') &&
!src.startsWith('data:') &&
(src.startsWith('http://') || src.startsWith('https://'))
const cachedSrc = useCachedThumbnail(shouldUseCache ? src : undefined)
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
const [isImageLoading, setIsImageLoading] = useState(true)
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
const isLoading = isCacheLoading || isImageLoading
useEffect(() => {
if (imageSrc) {
setIsImageLoading(true)
} else {
setIsImageLoading(false)
}
}, [imageSrc])
useEffect(() => {
onLoadingChange?.(isLoading)
}, [isLoading, onLoadingChange])
const defaultLoadingIcon = <Loader2 className="h-6 w-6 animate-spin" />
const displayLoadingIcon = loadingIcon ?? defaultLoadingIcon
return (
<ImageWithPlaceholder
src={imageSrc}
alt={alt}
className={className}
placeholderClassName={placeholderClassName}
fallbackIcon={isLoading ? displayLoadingIcon : fallbackIcon}
onError={onError}
onLoad={() => setIsImageLoading(false)}
/>
)
}

View File

@@ -7,9 +7,11 @@ import {
} 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import '../../assets/title-bar.css'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
@@ -17,10 +19,12 @@ 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'
import MingcuteRssLine from '~icons/mingcute/rss-line'
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
type Page = 'home' | 'settings' | 'about' | 'sites'
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
interface NavigationItem {
id: Page
@@ -40,10 +44,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' }
]
const languageOptions = languageList
const navigationItems: NavigationItem[] = [
{
@@ -54,6 +55,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
},
label: t('menu.download')
},
{
id: 'subscriptions',
icon: {
active: MingcuteRssFill,
inactive: MingcuteRssLine
},
label: t('menu.rss')
},
{
id: 'sites',
icon: {
@@ -83,11 +92,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
}
]
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
const activeLanguageCode = normalizeLanguageCode(i18n.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: string) => {
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
@@ -103,21 +112,15 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (
<div key={item.id} className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
{showLabel && (
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
{item.label}
@@ -128,9 +131,9 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
}
return (
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
{/* App Logo */}
<div className="flex flex-col items-center gap-1 py-4 mb-1">
<div className="flex flex-col items-center gap-1 py-3 mt-4">
<div className="w-12 h-12 flex items-center justify-center">
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
</div>
@@ -150,7 +153,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="w-12 h-12">
<Button variant="ghost" size="icon" className="no-drag w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
@@ -167,9 +170,13 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold' : undefined}
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
aria-current={isActive}
>
{option.label}
<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>
)
})}
@@ -190,7 +197,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>

View File

@@ -7,11 +7,15 @@ import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
import { ipcEvents, ipcServices } from '../../lib/ipc'
import '../../assets/title-bar.css'
export function TitleBar() {
interface TitleBarProps {
platform?: string
}
export function TitleBar({ platform }: TitleBarProps) {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
// 监听窗口最大化状态变化
// Listen for window maximize state changes
const handleMaximized = () => {
setIsMaximized(true)
}
@@ -41,8 +45,17 @@ export function TitleBar() {
ipcServices.window.close()
}
const isMac = platform === 'darwin'
const containerClass = `flex drag-region bg-background select-none ${
isMac ? 'h-10 items-center px-4' : 'justify-end pt-4 px-5'
}`
if (isMac) {
return <div className={containerClass} />
}
return (
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
<div className={containerClass}>
{/* Window controls */}
<div className="flex items-center gap-1 no-drag">
<Button

View File

@@ -41,44 +41,46 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
]
return (
<Card>
<CardHeader>
<CardTitle>{t('audioExtract.title')}</CardTitle>
<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="space-y-2">
<Label>{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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={setExtractFormat}>
<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={setExtractQuality}>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => onExtract('extract')} className="w-full">
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
{t('audioExtract.extract')}
</Button>
</CardContent>

View File

@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -73,9 +72,22 @@ export function FormatSelector({
useEffect(() => {
// Filter and sort formats
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
const videos = formats.filter(
(f) =>
f.video_ext !== 'none' &&
f.vcodec &&
f.vcodec !== 'none' &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
const audios = formats.filter(
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
(f) =>
f.acodec &&
f.acodec !== 'none' &&
(f.video_ext === 'none' || !f.video_ext) &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
// Apply showMoreFormats filter
@@ -87,6 +99,48 @@ export function FormatSelector({
? audios
: audios.filter((f) => f.ext !== 'webm')
// Sort formats by quality (best first)
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by height (higher is better)
const aHeight = a.height ?? 0
const bHeight = b.height ?? 0
if (aHeight !== bHeight) {
return bHeight - aHeight
}
// If same height, sort by fps (higher is better)
const aFps = a.fps ?? 0
const bFps = b.fps ?? 0
if (aFps !== bFps) {
return bFps - aFps
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by bitrate/quality if available
const aQuality = a.tbr ?? a.quality ?? 0
const bQuality = b.tbr ?? b.quality ?? 0
if (aQuality !== bQuality) {
return bQuality - aQuality
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
filteredVideos.sort(sortVideoFormatsByQuality)
filteredAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
@@ -121,25 +175,50 @@ export function FormatSelector({
}
const formatVideoLabel = (format: VideoFormat) => {
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
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)
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
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)
return `${quality} | ${ext} | ${size}`
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
if (type === 'video') {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('download.selectVideoFormat')}</Label>
<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) => {
@@ -147,25 +226,25 @@ export function FormatSelector({
onVideoFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatVideoLabel(format)}
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('download.selectAudioFormat')}</Label>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
@@ -173,18 +252,20 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
<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="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatAudioLabel(format)}
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
@@ -196,8 +277,8 @@ export function FormatSelector({
// Audio only
return (
<div className="space-y-2">
<Label>{t('download.selectFormat')}</Label>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
@@ -205,17 +286,17 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-[300px] p-1.5">
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatAudioLabel(format)}
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -115,53 +115,59 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
}
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
<div className="space-y-5">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
<ArrowLeft className="h-4 w-4" />
{t('download.back')}
</Button>
<Card>
<CardHeader>
<Card className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex flex-col md:flex-row gap-6">
{/* Thumbnail */}
<div className="shrink-0">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full md:w-80 rounded-lg aspect-video"
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
fallbackIcon={<Play className="h-12 w-12" />}
/>
</div>
{/* Video Metadata */}
<div className="flex-1 space-y-3">
<div>
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-3">
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
<CardDescription className="flex flex-wrap gap-2 items-center">
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
{formatDuration(videoInfo.duration)}
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Clock className="h-3.5 w-3.5" />
<span>{formatDuration(videoInfo.duration)}</span>
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1">
<Eye className="h-3 w-3" />
{formatViews(videoInfo.view_count)}
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Eye className="h-3.5 w-3.5" />
<span>{formatViews(videoInfo.view_count)}</span>
</Badge>
)}
{videoInfo.uploader && (
<Badge variant="outline" className="px-2.5 py-1">
{videoInfo.uploader}
</Badge>
)}
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
</CardDescription>
</div>
<div className="space-y-2">
<Label htmlFor={titleId}>{t('download.title')}</Label>
<div className="space-y-2.5">
<Label htmlFor={titleId} className="text-sm font-semibold">
{t('download.title')}
</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
className="font-medium h-10"
/>
</div>
</div>
@@ -170,14 +176,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
<Separator />
<CardContent className="pt-6 space-y-6">
<CardContent className="pt-6 pb-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="video" className="text-sm font-medium">
{t('download.video')}
</TabsTrigger>
<TabsTrigger value="audio" className="text-sm font-medium">
{t('download.audio')}
</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-4 mt-4">
<TabsContent value="video" className="space-y-5 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
@@ -194,13 +204,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
<Button
onClick={() => handleDownload('video')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadVideo')}
</Button>
</TabsContent>
<TabsContent value="audio" className="space-y-4 mt-4">
<TabsContent value="audio" className="space-y-5 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
@@ -218,7 +233,12 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
<Button
onClick={() => handleDownload('audio')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadAudio')}
</Button>

View File

@@ -1,98 +1,26 @@
export interface PopularSite {
id: string
label: string
description?: string
url: string
domain: string
}
export const popularSites: PopularSite[] = [
{
id: 'youtube',
label: 'YouTube',
description: 'Long-form and livestream video from creators worldwide.'
},
{
id: 'youtubemusic',
label: 'YouTube Music',
description: 'Official music videos, albums, and live performances.'
},
{
id: 'tiktok',
label: 'TikTok',
description: 'Short-form mobile videos, effects, and live streams.'
},
{
id: 'facebook',
label: 'Facebook',
description: 'Feed, Watch, and Reels videos from public pages.'
},
{
id: 'instagram',
label: 'Instagram',
description: 'Feed, Stories, Reels, and Highlights content.'
},
{
id: 'twitter',
label: 'X (Twitter)',
description: 'Timeline posts, Spaces recordings, and broadcasts.'
},
{
id: 'soundcloud',
label: 'SoundCloud',
description: 'Music tracks, playlists, and DJ sets.'
},
{
id: 'reddit',
label: 'Reddit',
description: 'Embedded clips and hosted videos from communities.'
},
{
id: 'vimeo',
label: 'Vimeo',
description: 'High-quality creator and business video hosting.'
},
{
id: 'dailymotion',
label: 'Dailymotion',
description: 'Global news, sports, and entertainment clips.'
},
{
id: 'twitch',
label: 'Twitch',
description: 'Gaming, music, and IRL live streams and VODs.'
},
{
id: 'linkedin',
label: 'LinkedIn',
description: 'Professional talks, webinars, and learning videos.'
},
{
id: 'pinterest',
label: 'Pinterest',
description: 'Idea pins, how-to reels, and lifestyle inspiration videos.'
},
{
id: 'tumblr',
label: 'Tumblr',
description: 'Creative short-form media and fan edits.'
},
{
id: 'mixcloud',
label: 'Mixcloud',
description: 'DJ mixes, radio shows, and long-form audio.'
},
{
id: 'niconico',
label: 'Niconico',
description: 'Japanese animation, music, and live broadcast archive.'
},
{
id: 'kick',
label: 'Kick',
description: 'Creator live streams and replays on the Kick platform.'
},
{
id: 'bandcamp',
label: 'Bandcamp',
description: 'Independent artist albums and community releases.'
}
{ id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },
{ id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },
{ id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },
{ id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },
{ id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },
{ id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },
{ id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },
{ id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },
{ id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },
{ id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },
{ id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },
{ id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },
{ id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },
{ id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },
{ id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },
{ id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },
{ id: 'kick', url: 'https://kick.com', domain: 'kick.com' },
{ id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }
]

View File

@@ -1,5 +1,5 @@
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { useEffect, useState } from 'react'
import { ipcServices } from '../lib/ipc'
export const useCachedThumbnail = (url?: string | null): string | undefined => {
@@ -14,7 +14,11 @@ export const useCachedThumbnail = (url?: string | null): string | undefined => {
return
}
if (url.startsWith('file://') || url.startsWith('data:')) {
if (
url.startsWith(APP_PROTOCOL_SCHEME) ||
url.startsWith('file://') ||
url.startsWith('data:')
) {
setCachedUrl(url)
return
}

View File

@@ -1,15 +1,35 @@
import { defaultLanguageCode, supportedLanguageCodes } from '@shared/languages'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import zh from './locales/zh.json'
type TranslationDictionary = typeof en
const localeModules = import.meta.glob<{ default: TranslationDictionary }>('./locales/*.json', {
eager: true
})
const translations = Object.fromEntries(
Object.entries(localeModules).map(([path, module]) => {
const code = path.replace('./locales/', '').replace('.json', '')
return [code, module.default]
})
) as Record<string, TranslationDictionary>
const resources = Object.fromEntries(
supportedLanguageCodes.map((code) => [
code,
{
translation: translations[code] ?? en
}
])
)
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: 'en',
fallbackLng: 'en',
resources,
lng: defaultLanguageCode,
fallbackLng: defaultLanguageCode,
supportedLngs: supportedLanguageCodes,
interpolation: {
escapeValue: false
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "التحقق من التحديثات",
"download": "تحميل",
"email": "البريد الإلكتروني",
"feedback": "ملاحظات",
"goToDownload": "الانتقال إلى صفحة التحميل",
"openRepo": "فتح مستودع GitHub",
"view": "عرض",
"visit": "زيارة"
},
"appName": "VidBee",
"autoUpdateDescription": "تنزيل وتثبيت الإصدارات الجديدة تلقائياً في الخلفية.",
"autoUpdateTitle": "التحديثات التلقائية",
"betaProgramDescription": "تلقي البناءات المبكرة والميزات القادمة قبل أي شخص آخر.",
"betaProgramTitle": "قناة المعاينة",
"description": "VidBee هو برنامج تنزيل مجاني ومفتوح المصدر مبني باستخدام Electron ويعمل بواسطة yt-dlp.",
"followAuthorActions": {
"follow": "متابعة @nexmoex"
},
"followAuthorDescription": "ابق على اطلاع بآخر أخبار وتحديثات VidBee.",
"followAuthorSupport": "تابع المطور على X (Twitter) للحصول على آخر التحديثات والأخبار حول VidBee.",
"followAuthorTitle": "متابعة المطور",
"here": "هنا",
"homepage": "الصفحة الرئيسية",
"notifications": {
"checkingUpdates": "البحث عن التحديثات...",
"downloadError": "فشل في تنزيل التحديث",
"downloadStarted": "بدأ التحميل...",
"downloadUpdate": "تنزيل وتثبيت التحديث {{version}}؟",
"manualDownloadAction": "تنزيل الآن",
"noUpdatesAvailable": "أنت تستخدم أحدث إصدار",
"restartToUpdate": "إعادة التشغيل الآن لتثبيت التحديث؟",
"restartNowAction": "إعادة التشغيل الآن",
"updateAvailable": "تحديث متاح: {{version}}",
"updateAvailableMessage": "إصدار جديد {{version}} متاح. يرجى تنزيله من الموقع الرسمي.",
"updateDownloaded": "تم تنزيل التحديث، أعد التشغيل للتثبيت",
"updateDownloadedVersion": "تم تنزيل التحديث {{version}}، أعد التشغيل للتثبيت",
"updateError": "فشل في التحقق من التحديثات: {{error}}",
"unknownErrorFallback": "خطأ غير معروف"
},
"preferencesDescription": "ضبط إعدادات التحديث دون مغادرة هذه الصفحة.",
"preferencesTitle": "التبديلات السريعة",
"resources": {
"changelog": "ملاحظات الإصدار",
"changelogDescription": "اطلع على ما تغير في كل إصدار.",
"contact": "دعم البريد الإلكتروني",
"contactDescription": "تواصل مباشرة للحصول على المساعدة أو التعاون.",
"documentation": "مركز المساعدة",
"documentationDescription": "أدلة، أسئلة شائعة، وسير عمل شائعة.",
"feedback": "ملاحظات ومشاكل",
"feedbackDescription": "شارك الأفكار أو أبلغ عن المشاكل على GitHub.",
"license": "الترخيص",
"licenseDescription": "راجع شروط ترخيص المصدر المفتوح.",
"website": "الموقع الرسمي",
"websiteDescription": "أبرز المنتجات، خارطة الطريق، وأخبار المجتمع."
},
"resourcesDescription": "روابط مفيدة لمعرفة المزيد عن VidBee والبقاء على اتصال.",
"resourcesTitle": "الموارد",
"shareActions": {
"copy": "نسخ الرابط",
"facebook": "مشاركة على Facebook",
"twitter": "مشاركة على X (Twitter)"
},
"shareDescription": "شارك VidBee مع مجتمعك بنقرة واحدة.",
"shareSupport": "أوصِ VidBee لأصدقائك لدعم نمونا وتحديثاتنا.",
"shareTitle": "انشر الكلمة",
"sourceCode": "الكود المصدري متاح",
"title": "حول",
"version": "الإصدار",
"versionLabel": "v{{version}}",
"latestVersionBadge": "الأحدث: v{{version}}",
"latestVersionStatus": {
"available": "إصدار جديد متاح",
"uptodate": "أنت محدث",
"error": "تعذر جلب أحدث إصدار"
},
"downloadingUpdate": "تنزيل التحديث"
},
"advancedOptions": {
"closeWhenDone": "إغلاق التطبيق عند انتهاء التحميل",
"currentLocation": "موقع التحميل الحالي - ",
"downloadLocation": "موقع التحميل",
"downloadSubs": "تحميل الترجمات إن كانت متاحة",
"end": "النهاية",
"endHint": "إذا تُرك فارغاً، سيتم التحميل حتى النهاية",
"endPlaceholder": "10:00",
"selectLocation": "اختر موقع التحميل",
"start": "البداية",
"startHint": "إذا تُرك فارغاً، سيبدأ من البداية",
"startPlaceholder": "00:00",
"subtitles": "الترجمات",
"timeRange": "تحميل نطاق زمني محدد",
"title": "خيارات متقدمة"
},
"app": {
"description": "تحميل الفيديوهات والصوتيات من مئات المواقع",
"title": "VidBee"
},
"audioExtract": {
"bad": "سيء",
"best": "أفضل",
"extract": "استخراج",
"good": "جيد",
"normal": "عادي",
"selectFormat": "اختر التنسيق",
"selectQuality": "اختر الجودة",
"title": "استخراج الصوت",
"worst": "أسوأ"
},
"download": {
"active": "نشط",
"all": "الكل",
"audio": "صوت",
"back": "رجوع",
"cancel": "إلغاء",
"cancelled": "ملغي",
"clearCompleted": "مسح المكتملة",
"clearDownloads": "مسح التحميلات",
"completed": "مكتمل",
"downloadAudio": "تحميل الصوت",
"downloadBtn": "تحميل",
"downloadPending": "قيد الانتظار",
"downloadQueue": "قائمة انتظار التحميل",
"downloadVideo": "تحميل الفيديو",
"downloading": "جاري التحميل...",
"enterUrl": "أدخل رابط الفيديو",
"enterUrlDescription": "الصق أو اكتب رابط فيديو. ",
"error": "خطأ",
"fetch": "جلب",
"fetchingVideoInfo": "جلب معلومات الفيديو...",
"history": "السجل",
"imageLoadError": "فشل تحميل الصورة",
"imagePlaceholder": "لا توجد صورة متاحة",
"infoUnavailable": "تحميل بنقرة واحدة (المعلومات غير متاحة)",
"loading": "جاري التحميل",
"moreOptions": "خيارات إضافية",
"noActiveDownloads": "لا توجد تحميلات نشطة",
"noAudio": "لا يوجد صوت",
"noHistory": "لا يوجد سجل تحميل",
"noItems": "لم يتم العثور على عناصر",
"goToSettings": "انتقل إلى الإعدادات",
"oneClickDownload": "تحميل بنقرة واحدة",
"oneClickDownloadDescription": "تحميل مباشر بالإعدادات الافتراضية دون تأكيد",
"oneClickDownloadEnabled": "تم تفعيل التحميل بنقرة واحدة. ستبدأ التحميلات مباشرة بالإعدادات الافتراضية.",
"oneClickDownloadNow": "تحميل الآن",
"oneClickDownloadStarted": "بدأ التحميل بالإعدادات الافتراضية",
"paste": "لصق",
"pastePlaylistUrl": "انقر للصق رابط قائمة التشغيل من الحافظة [Ctrl + V]",
"pasteUrl": "انقر للصق رابط أو معرف الفيديو [Ctrl + V]",
"preparing": "جاري التحضير...",
"processing": "جاري المعالجة",
"progress": "التقدم",
"showDetails": "إظهار التفاصيل",
"hideDetails": "إخفاء التفاصيل",
"selectAudioFormat": "اختر تنسيق الصوت",
"selectFormat": "اختر التنسيق",
"selectVideoFormat": "اختر تنسيق الفيديو",
"singleVideo": "فيديو واحد",
"speed": "السرعة",
"title": "العنوان",
"total": "الإجمالي",
"unknownQuality": "جودة غير معروفة",
"unknownSize": "حجم غير معروف",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "فيديو",
"videoInfo": "معلومات الفيديو",
"videoInfoUpdated": "تم تحديث معلومات الفيديو",
"metadata": {
"source": "المصدر",
"playlist": "قائمة التشغيل",
"format": "التنسيق",
"quality": "الجودة",
"codec": "الترميز",
"savedFile": "الملف المحفوظ",
"url": "رابط المصدر",
"description": "الوصف",
"views": "المشاهدات",
"tags": "العلامات",
"downloadPath": "مسار التحميل",
"createdAt": "تم الإنشاء في",
"startedAt": "بدأ في",
"completedAt": "اكتمل في",
"speed": "السرعة",
"fileSize": "حجم الملف",
"width": "العرض",
"height": "الارتفاع",
"fps": "معدل الإطارات",
"videoCodec": "ترميز الفيديو",
"audioCodec": "ترميز الصوت",
"formatNote": "ملاحظة التنسيق",
"protocol": "البروتوكول",
"subscription": "الاشتراك"
}
},
"errors": {
"clickToCopy": "انقر لنسخ التفاصيل",
"clipboardEmpty": "الحافظة فارغة",
"downloadFailed": "فشل التحميل",
"downloadNecessaryFilesFailed": "فشل في تحميل الملفات الضرورية. يرجى التحقق من شبكتك والمحاولة مرة أخرى",
"emptyUrl": "يرجى إدخال رابط",
"errorDetails": "تفاصيل الخطأ",
"fetchInfoFailed": "فشل في جلب معلومات الفيديو",
"networkError": "حدث خطأ ما. تحقق من شبكتك واستخدم رابطاً صحيحاً",
"pasteFromClipboard": "فشل في اللصق من الحافظة"
},
"history": {
"clearCancelled": "مسح الملغاة",
"clearCompleted": "مسح المكتملة",
"clearErrors": "مسح الأخطاء",
"copyToClipboard": "نسخ إلى الحافظة",
"copyUrl": "نسخ الرابط",
"date": "التاريخ",
"description": "عرض وإدارة سجل التحميل الخاص بك",
"duration": "المدة",
"fileSize": "حجم الملف",
"filters": {
"all": "الكل",
"cancelled": "ملغي",
"completed": "مكتمل",
"errors": "أخطاء"
},
"noHistory": "لا يوجد سجل تحميل بعد",
"noHistoryDescription": "ستظهر تحميلاتك المكتملة هنا",
"openDownloadFolder": "فتح مجلد التحميل",
"openFile": "فتح الملف",
"openFileLocation": "فتح موقع الملف",
"openFolder": "فتح المجلد",
"openInBrowser": "انقر لفتح في المتصفح",
"removeItem": "إزالة العنصر",
"stats": {
"cancelled": "ملغي",
"completed": "مكتمل",
"errors": "أخطاء",
"total": "الإجمالي"
},
"status": {
"cancelled": "ملغي",
"completed": "مكتمل",
"error": "خطأ"
},
"title": "سجل التحميل"
},
"menu": {
"about": "حول",
"download": "تحميل",
"playlist": "تحميل قائمة التشغيل",
"rss": "RSS",
"subscriptions": "الاشتراكات",
"preferences": "التفضيلات",
"supportedSites": "المواقع المدعومة",
"theme": "المظهر:"
},
"notifications": {
"copyFailed": "فشل في النسخ إلى الحافظة",
"downloadCompleted": "اكتمل التحميل",
"downloadFailed": "فشل التحميل",
"downloadStarted": "بدأ التحميل",
"itemRemoved": "تم إزالة العنصر",
"openFileFailed": "فشل في فتح الملف",
"openFolderFailed": "فشل في فتح المجلد",
"removeFailed": "فشل في إزالة العنصر",
"settingsSaved": "تم حفظ الإعدادات",
"urlCopied": "تم نسخ الرابط إلى الحافظة",
"videoCopied": "تم نسخ الفيديو إلى الحافظة"
},
"playlist": {
"badgeLabel": "قائمة التشغيل",
"clearPreview": "مسح المعاينة",
"comingSoon": "ميزة تحميل قائمة التشغيل قريباً!",
"completed": "تم تحميل قائمة التشغيل",
"description": "تحميل جميع الفيديوهات من قائمة تشغيل أو قناة YouTube",
"downloadFailed": "فشل في بدء تحميل قائمة التشغيل",
"downloadPlaylist": "تحميل قائمة التشغيل",
"downloadStarted": "بدأ تحميل {{count}} فيديو من قائمة التشغيل",
"downloadType": "نوع التحميل",
"downloading": "جاري تحميل قائمة التشغيل:",
"endIndex": "النهاية",
"enterPlaylistUrl": "أدخل رابط قائمة التشغيل",
"fetchFailed": "فشل في جلب معلومات قائمة التشغيل",
"filenameFormat": "تنسيق اسم الملف لقوائم التشغيل",
"folderFormat": "تنسيق اسم المجلد لقوائم التشغيل",
"foundVideos": "تم العثور على {{count}} فيديو في قائمة التشغيل",
"groupActive": "{{count}} نشط",
"groupErrors": "{{count}} فشل",
"groupSummary": "{{completed}} / {{total}} مكتمل",
"linkLabel": "رابط قائمة التشغيل",
"noEntries": "لم يتم العثور على فيديوهات في قائمة التشغيل هذه",
"noEntriesInRange": "لا توجد فيديوهات في النطاق المحدد",
"noRangeSelected": "لم يتم تعيين النهاية - تم تحديد قائمة التشغيل الكاملة",
"playlistUrlDescription": "تحميل جميع الفيديوهات من قائمة تشغيل بشكل مجمع",
"positionLabel": "العنصر {{index}} من {{total}}",
"previewButton": "معاينة قائمة التشغيل",
"previewFailed": "فشل في معاينة قائمة التشغيل",
"previewSummary": "معاينة عناصر قائمة التشغيل قبل التحميل.",
"previewRequired": "معاينة قائمة التشغيل قبل التحميل.",
"range": "النطاق (اختياري)",
"resetToDefault": "إعادة تعيين إلى الافتراضي",
"selectedRange": "النطاق: {{start}}-{{end}}",
"showingCount": "عرض {{count}} فيديو",
"startIndex": "البداية (1)",
"title": "تحميل قائمة التشغيل",
"totalVideos": "إجمالي الفيديوهات: {{count}}",
"untitled": "قائمة تشغيل بدون عنوان"
},
"settings": {
"aboutTab": "حول",
"advanced": "متقدم",
"app": "إعدادات التطبيق",
"audio": "تفضيلات الصوت",
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
"cookiesFile": "ملف ملفات تعريف الارتباط",
"cookiesFileDescription": "ملف ملفات تعريف الارتباط بتنسيق Netscape للتحميل للمصادقة",
"clearCookiesFile": "مسح",
"cookiesHelpTitle": "استخدام ملفات تعريف الارتباط",
"cookiesHelpBrowser": "اختر متصفحك أعلاه لإعادة استخدام جلسته الموقعة تلقائياً.",
"cookiesHelpFile": "قم بتصدير ملف ملفات تعريف الارتباط Netscape (راجع الأسئلة الشائعة لـ yt-dlp) واختره هنا عند الحاجة.",
"cookiesHelpFaq": "فتح الأسئلة الشائعة لملفات تعريف الارتباط في yt-dlp",
"openLinkError": "فشل في فتح الرابط",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "استخدام ملف التكوين",
"configFileDescription": "ملف تكوين مخصص لـ yt-dlp",
"clearConfigFile": "مسح",
"dark": "داكن",
"description": "تكوين تفضيلات التحميل وإعدادات التطبيق",
"directorySelectError": "فشل في اختيار المجلد",
"downloadPath": "موقع التحميل",
"downloadPathDescription": "اختر مكان حفظ الملفات المحملة",
"fileSelectError": "فشل في اختيار الملف",
"general": "عام",
"language": "اللغة",
"light": "فاتح",
"hideDockIcon": "إخفاء أيقونة Dock",
"hideDockIconDescription": "إزالة VidBee من Dock في macOS. استخدم شريط القائمة أو أيقونة الدرج لإعادة فتح التطبيق.",
"launchAtLogin": "بدء التشغيل عند تسجيل الدخول",
"launchAtLoginDescription": "فتح VidBee تلقائياً بعد تسجيل الدخول إلى جهاز الكمبيوتر الخاص بك.",
"launchAtLoginUnsupported": "البدء التلقائي متاح فقط على macOS و Windows.",
"enableAnalytics": "مساعدة في تحسين VidBee",
"enableAnalyticsDescription": "مشاركة بيانات الاستخدام المجهولة لمساعدتنا في فهم كيفية استخدام التطبيق وأولويات التحسينات.",
"maxConcurrentDownloads": "العدد الأقصى للتحميلات النشطة",
"maxConcurrentDownloadsDescription": "العدد الأقصى للتحميلات المتزامنة",
"none": "لا شيء",
"oneClickDownload": "تحميل بنقرة واحدة",
"oneClickDownloadDescription": "تفعيل التحميل بنقرة واحدة بالإعدادات الافتراضية",
"oneClickDownloadType": "نوع التحميل الافتراضي",
"oneClickDownloadTypeDescription": "اختر نوع التحميل الافتراضي للتحميلات بنقرة واحدة. تستخدم الجودة الإعداد المسبق أدناه.",
"oneClickQuality": "الجودة المفضلة",
"oneClickQualityDescription": "اختر الإعداد المسبق للجودة المستخدم للتحميلات بنقرة واحدة",
"oneClickQualityOptions": {
"auto": "تلقائي",
"bad": "سيء",
"best": "أفضل",
"good": "جيد",
"normal": "عادي",
"worst": "أسوأ"
},
"proxy": "الوكيل",
"proxyDescription": "خادم الوكيل لطلبات الشبكة",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "اختر ملف التكوين",
"selectPath": "اختر",
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
"subscriptionDefaults": {
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
},
"system": "النظام",
"theme": "المظهر",
"themeDescription": "اختر مظهراً فاتحاً أو داكناً أو نظامياً لـ VidBee",
"title": "الإعدادات",
"tray": {
"quit": "إنهاء",
"showHome": "إظهار الصفحة الرئيسية"
},
"video": "تفضيلات الفيديو"
},
"subscriptions": {
"title": "الاشتراكات",
"subtitle": "{{count}} اشتراك{{count, plural, one {} other {}}}",
"description": "مراقبة تغذيات RSS تلقائياً ووضع التحميلات الجديدة في قائمة الانتظار دون عمل يدوي.",
"defaults": {
"title": "الإعدادات الافتراضية للأتمتة",
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
"downloadDirectory": "مجلد التحميل",
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
"checkInterval": "فترة الفحص (ساعات)",
"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": "لم يتم العثور على عناصر تغذية حديثة.",
"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."
},
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{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؟ استخدم RSSHub لإنشاء تغذيات RSS لـ YouTube و Twitter والآلاف من المنصات الأخرى."
}
},
"sites": {
"homeInlineDescription": "يدعم {{sites}} والمزيد.",
"moreDescription": "يتم تحديث قائمة yt-dlp الكاملة باستمرار من قبل المجتمع.",
"moreTitle": "تحتاج موقعاً آخر؟",
"openFullList": "فتح قائمة المواقع المدعومة الكاملة",
"pageDescription": "يستخدم VidBee yt-dlp تحت الغطاء للوصول إلى مئات المصادر.",
"pageIntro": "إليك الخدمات الرئيسية التي يقوم الناس بتحميلها منها في أغلب الأحيان.",
"pageTitle": "المواقع المدعومة",
"popular": {
"bandcamp": {
"description": "ألبومات الفنانين المستقلين وإصدارات المجتمع.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "الأخبار العالمية والرياضة ومقاطع الترفيه.",
"label": "Dailymotion"
},
"facebook": {
"description": "مقاطع فيديو Feed و Watch و Reels من الصفحات العامة.",
"label": "Facebook"
},
"instagram": {
"description": "محتوى Feed و Stories و Reels و Highlights.",
"label": "Instagram"
},
"kick": {
"description": "البث المباشر للمبدعين وإعادة التشغيل على منصة Kick.",
"label": "Kick"
},
"linkedin": {
"description": "المحادثات المهنية والندوات عبر الويب ومقاطع الفيديو التعليمية.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "مزج DJ وبرامج الراديو والصوت طويل الشكل.",
"label": "Mixcloud"
},
"niconico": {
"description": "أرشيف الرسوم المتحركة اليابانية والموسيقى والبث المباشر.",
"label": "Niconico"
},
"pinterest": {
"description": "دبابيس الأفكار ومقاطع كيفية القيام بذلك ومقاطع فيديو إلهام نمط الحياة.",
"label": "Pinterest"
},
"reddit": {
"description": "المقاطع المضمنة ومقاطع الفيديو المستضافة من المجتمعات.",
"label": "Reddit"
},
"soundcloud": {
"description": "مقاطع الموسيقى وقوائم التشغيل ومجموعات DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "مقاطع فيديو قصيرة للهاتف المحمول والتأثيرات والبث المباشر.",
"label": "TikTok"
},
"tumblr": {
"description": "الوسائط الإبداعية قصيرة الشكل وتحريرات المعجبين.",
"label": "Tumblr"
},
"twitch": {
"description": "البث المباشر للألعاب والموسيقى و IRL و VODs.",
"label": "Twitch"
},
"twitter": {
"description": "منشورات الجدول الزمني وتسجيلات Spaces والبث.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "استضافة فيديو عالية الجودة للمبدعين والأعمال.",
"label": "Vimeo"
},
"youtube": {
"description": "فيديو طويل الشكل والبث المباشر من المبدعين في جميع أنحاء العالم.",
"label": "YouTube"
},
"youtubemusic": {
"description": "مقاطع فيديو موسيقية رسمية وألبومات وعروض مباشرة.",
"label": "YouTube Music"
}
},
"popularSection": "المنصات الرئيسية",
"viewAll": "عرض جميع المواقع المدعومة"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Updates prüfen",
"download": "Herunterladen",
"email": "E-Mail",
"feedback": "Feedback",
"goToDownload": "Zur Download-Seite gehen",
"openRepo": "GitHub-Repository öffnen",
"view": "Ansehen",
"visit": "Besuchen"
},
"appName": "VidBee",
"autoUpdateDescription": "Neue Versionen automatisch im Hintergrund herunterladen und installieren.",
"autoUpdateTitle": "Automatische Updates",
"betaProgramDescription": "Erhalten Sie frühe Builds und kommende Funktionen vor allen anderen.",
"betaProgramTitle": "Vorschau-Kanal",
"description": "VidBee ist ein kostenloser, quelloffener Downloader, der mit Electron erstellt wurde und von yt-dlp angetrieben wird.",
"followAuthorActions": {
"follow": "Folgen Sie @nexmoex"
},
"followAuthorDescription": "Bleiben Sie auf dem Laufenden mit den neuesten VidBee-Nachrichten und Updates.",
"followAuthorSupport": "Folgen Sie dem Entwickler auf X (Twitter), um die neuesten Updates und Nachrichten über VidBee zu erhalten.",
"followAuthorTitle": "Dem Entwickler folgen",
"here": "hier",
"homepage": "Startseite",
"notifications": {
"checkingUpdates": "Suche nach Updates...",
"downloadError": "Update konnte nicht heruntergeladen werden",
"downloadStarted": "Download gestartet...",
"downloadUpdate": "Update {{version}} herunterladen und installieren?",
"manualDownloadAction": "Jetzt herunterladen",
"noUpdatesAvailable": "Sie verwenden die neueste Version",
"restartToUpdate": "Jetzt neu starten, um Update zu installieren?",
"restartNowAction": "Jetzt neu starten",
"updateAvailable": "Update verfügbar: {{version}}",
"updateAvailableMessage": "Eine neue Version {{version}} ist verfügbar. Bitte laden Sie sie von der offiziellen Website herunter.",
"updateDownloaded": "Update heruntergeladen, neu starten zum Installieren",
"updateDownloadedVersion": "Update {{version}} heruntergeladen, neu starten zum Installieren",
"updateError": "Update-Prüfung fehlgeschlagen: {{error}}",
"unknownErrorFallback": "Unbekannter Fehler"
},
"preferencesDescription": "Update-Einstellungen anpassen, ohne diese Seite zu verlassen.",
"preferencesTitle": "Schnellumschalter",
"resources": {
"changelog": "Versionshinweise",
"changelogDescription": "Informieren Sie sich über die Änderungen in jeder Version.",
"contact": "E-Mail-Support",
"contactDescription": "Kontaktieren Sie uns direkt für Hilfe oder Zusammenarbeit.",
"documentation": "Hilfezentrum",
"documentationDescription": "Anleitungen, FAQs und gängige Arbeitsabläufe.",
"feedback": "Feedback & Probleme",
"feedbackDescription": "Teilen Sie Ideen oder melden Sie Probleme auf GitHub.",
"license": "Lizenz",
"licenseDescription": "Überprüfen Sie die Bedingungen der Open-Source-Lizenz.",
"website": "Offizielle Website",
"websiteDescription": "Produkthighlights, Roadmap und Community-Nachrichten."
},
"resourcesDescription": "Nützliche Links, um mehr über VidBee zu erfahren und in Verbindung zu bleiben.",
"resourcesTitle": "Ressourcen",
"shareActions": {
"copy": "Link kopieren",
"facebook": "Auf Facebook teilen",
"twitter": "Auf X (Twitter) teilen"
},
"shareDescription": "Teilen Sie VidBee mit Ihrer Community mit einem Klick.",
"shareSupport": "Empfehlen Sie VidBee Ihren Freunden, um unser Wachstum und unsere Updates zu unterstützen.",
"shareTitle": "Verbreiten Sie die Nachricht",
"sourceCode": "Quellcode ist verfügbar",
"title": "Über",
"version": "Version",
"versionLabel": "v{{version}}",
"latestVersionBadge": "Neueste: v{{version}}",
"latestVersionStatus": {
"available": "Neue Version verfügbar",
"uptodate": "Sie sind auf dem neuesten Stand",
"error": "Neueste Version konnte nicht abgerufen werden"
},
"downloadingUpdate": "Update wird heruntergeladen"
},
"advancedOptions": {
"closeWhenDone": "App schließen, wenn Download abgeschlossen ist",
"currentLocation": "Aktueller Download-Speicherort - ",
"downloadLocation": "Download-Speicherort",
"downloadSubs": "Untertitel herunterladen, falls verfügbar",
"end": "Ende",
"endHint": "Wenn leer gelassen, wird bis zum Ende heruntergeladen",
"endPlaceholder": "10:00",
"selectLocation": "Download-Speicherort auswählen",
"start": "Start",
"startHint": "Wenn leer gelassen, wird vom Anfang an gestartet",
"startPlaceholder": "00:00",
"subtitles": "Untertitel",
"timeRange": "Bestimmten Zeitbereich herunterladen",
"title": "Erweiterte Optionen"
},
"app": {
"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",
"audio": "Audio",
"back": "Zurück",
"cancel": "Abbrechen",
"cancelled": "Abgebrochen",
"clearCompleted": "Abgeschlossene löschen",
"clearDownloads": "Downloads löschen",
"completed": "Abgeschlossen",
"downloadAudio": "Audio herunterladen",
"downloadBtn": "Herunterladen",
"downloadPending": "Ausstehend",
"downloadQueue": "Download-Warteschlange",
"downloadVideo": "Video herunterladen",
"downloading": "Wird heruntergeladen...",
"enterUrl": "Video-URL eingeben",
"enterUrlDescription": "Fügen Sie eine Video-URL ein oder geben Sie sie ein. ",
"error": "Fehler",
"fetch": "Abrufen",
"fetchingVideoInfo": "Video-Informationen werden abgerufen...",
"history": "Verlauf",
"imageLoadError": "Bild konnte nicht geladen werden",
"imagePlaceholder": "Kein Bild verfügbar",
"infoUnavailable": "Ein-Klick-Download (Info nicht verfügbar)",
"loading": "Wird geladen",
"moreOptions": "Weitere Optionen",
"noActiveDownloads": "Keine aktiven Downloads",
"noAudio": "Kein Audio",
"noHistory": "Kein Download-Verlauf",
"noItems": "Keine Elemente gefunden",
"goToSettings": "Zu Einstellungen gehen",
"oneClickDownload": "Ein-Klick-Download",
"oneClickDownloadDescription": "Direkt mit Standardeinstellungen ohne Bestätigung herunterladen",
"oneClickDownloadEnabled": "Ein-Klick-Download ist aktiviert. Downloads starten direkt mit Standardeinstellungen.",
"oneClickDownloadNow": "Jetzt herunterladen",
"oneClickDownloadStarted": "Download mit Standardeinstellungen gestartet",
"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]",
"preparing": "Wird vorbereitet...",
"processing": "Wird verarbeitet",
"progress": "Fortschritt",
"showDetails": "Details anzeigen",
"hideDetails": "Details ausblenden",
"selectAudioFormat": "Audio-Format auswählen",
"selectFormat": "Format auswählen",
"selectVideoFormat": "Video-Format auswählen",
"singleVideo": "Einzelnes Video",
"speed": "Geschwindigkeit",
"title": "Titel",
"total": "Gesamt",
"unknownQuality": "Unbekannte Qualität",
"unknownSize": "Unbekannte Größe",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Video-Informationen",
"videoInfoUpdated": "Video-Informationen aktualisiert",
"metadata": {
"source": "Quelle",
"playlist": "Playlist",
"format": "Format",
"quality": "Qualität",
"codec": "Codec",
"savedFile": "Gespeicherte Datei",
"url": "Quell-URL",
"description": "Beschreibung",
"views": "Aufrufe",
"tags": "Tags",
"downloadPath": "Download-Pfad",
"createdAt": "Erstellt am",
"startedAt": "Gestartet am",
"completedAt": "Abgeschlossen am",
"speed": "Geschwindigkeit",
"fileSize": "Dateigröße",
"width": "Breite",
"height": "Höhe",
"fps": "FPS",
"videoCodec": "Video-Codec",
"audioCodec": "Audio-Codec",
"formatNote": "Format-Hinweis",
"protocol": "Protokoll",
"subscription": "Abonnement"
}
},
"errors": {
"clickToCopy": "Klicken, um Details zu kopieren",
"clipboardEmpty": "Zwischenablage ist leer",
"downloadFailed": "Download fehlgeschlagen",
"downloadNecessaryFilesFailed": "Herunterladen der erforderlichen Dateien fehlgeschlagen. Bitte überprüfen Sie Ihr Netzwerk und versuchen Sie es erneut",
"emptyUrl": "Bitte geben Sie eine URL ein",
"errorDetails": "Fehlerdetails",
"fetchInfoFailed": "Video-Informationen konnten nicht abgerufen werden",
"networkError": "Ein Fehler ist aufgetreten. Überprüfen Sie Ihr Netzwerk und verwenden Sie die richtige URL",
"pasteFromClipboard": "Einfügen aus Zwischenablage fehlgeschlagen"
},
"history": {
"clearCancelled": "Abgebrochene löschen",
"clearCompleted": "Abgeschlossene löschen",
"clearErrors": "Fehler löschen",
"copyToClipboard": "In Zwischenablage kopieren",
"copyUrl": "URL kopieren",
"date": "Datum",
"description": "Ihren Download-Verlauf anzeigen und verwalten",
"duration": "Dauer",
"fileSize": "Dateigröße",
"filters": {
"all": "Alle",
"cancelled": "Abgebrochen",
"completed": "Abgeschlossen",
"errors": "Fehler"
},
"noHistory": "Noch kein Download-Verlauf",
"noHistoryDescription": "Ihre abgeschlossenen Downloads werden hier angezeigt",
"openDownloadFolder": "Download-Ordner öffnen",
"openFile": "Datei öffnen",
"openFileLocation": "Dateispeicherort öffnen",
"openFolder": "Ordner öffnen",
"openInBrowser": "Klicken, um im Browser zu öffnen",
"removeItem": "Element entfernen",
"stats": {
"cancelled": "Abgebrochen",
"completed": "Abgeschlossen",
"errors": "Fehler",
"total": "Gesamt"
},
"status": {
"cancelled": "Abgebrochen",
"completed": "Abgeschlossen",
"error": "Fehler"
},
"title": "Download-Verlauf"
},
"menu": {
"about": "Über",
"download": "Herunterladen",
"playlist": "Playlist herunterladen",
"rss": "RSS",
"subscriptions": "Abonnements",
"preferences": "Einstellungen",
"supportedSites": "Unterstützte Websites",
"theme": "Design:"
},
"notifications": {
"copyFailed": "Kopieren in Zwischenablage fehlgeschlagen",
"downloadCompleted": "Download abgeschlossen",
"downloadFailed": "Download fehlgeschlagen",
"downloadStarted": "Download gestartet",
"itemRemoved": "Element entfernt",
"openFileFailed": "Datei konnte nicht geöffnet werden",
"openFolderFailed": "Ordner konnte nicht geöffnet werden",
"removeFailed": "Element konnte nicht entfernt werden",
"settingsSaved": "Einstellungen gespeichert",
"urlCopied": "URL in Zwischenablage kopiert",
"videoCopied": "Video in Zwischenablage kopiert"
},
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Vorschau löschen",
"comingSoon": "Playlist-Download-Funktion kommt bald!",
"completed": "Playlist heruntergeladen",
"description": "Alle Videos aus einer YouTube-Playlist oder einem Kanal herunterladen",
"downloadFailed": "Playlist-Download konnte nicht gestartet werden",
"downloadPlaylist": "Playlist herunterladen",
"downloadStarted": "Download von {{count}} Videos aus Playlist gestartet",
"downloadType": "Download-Typ",
"downloading": "Playlist wird heruntergeladen:",
"endIndex": "Ende",
"enterPlaylistUrl": "Playlist-URL eingeben",
"fetchFailed": "Playlist-Informationen konnten nicht abgerufen werden",
"filenameFormat": "Dateinamenformat für Playlists",
"folderFormat": "Ordnernamenformat für Playlists",
"foundVideos": "{{count}} Videos in Playlist gefunden",
"groupActive": "{{count}} aktiv",
"groupErrors": "{{count}} fehlgeschlagen",
"groupSummary": "{{completed}} / {{total}} abgeschlossen",
"linkLabel": "Playlist-URL",
"noEntries": "In dieser Playlist wurden keine Videos gefunden",
"noEntriesInRange": "Keine Videos im ausgewählten Bereich",
"noRangeSelected": "Kein Ende gesetzt - vollständige Playlist ausgewählt",
"playlistUrlDescription": "Alle Videos aus einer Playlist in großen Mengen herunterladen",
"positionLabel": "Element {{index}} von {{total}}",
"previewButton": "Playlist-Vorschau",
"previewFailed": "Playlist-Vorschau fehlgeschlagen",
"previewSummary": "Playlist-Elemente vor dem Download in der Vorschau anzeigen.",
"previewRequired": "Playlist vor dem Download in der Vorschau anzeigen.",
"range": "Bereich (Optional)",
"resetToDefault": "Auf Standard zurücksetzen",
"selectedRange": "Bereich: {{start}}-{{end}}",
"showingCount": "{{count}} Videos werden angezeigt",
"startIndex": "Start (1)",
"title": "Playlist herunterladen",
"totalVideos": "Gesamt Videos: {{count}}",
"untitled": "Unbenannte Playlist"
},
"settings": {
"aboutTab": "Über",
"advanced": "Erweitert",
"app": "App-Einstellungen",
"audio": "Audio-Einstellungen",
"browserForCookies": "Browser für Cookies auswählen",
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
"cookiesFile": "Cookie-Datei",
"cookiesFileDescription": "Netscape-formatierte Cookie-Datei zum Laden für die Authentifizierung",
"clearCookiesFile": "Löschen",
"cookiesHelpTitle": "Cookies verwenden",
"cookiesHelpBrowser": "Wählen Sie oben Ihren Browser aus, um seine angemeldete Sitzung automatisch wiederzuverwenden.",
"cookiesHelpFile": "Exportieren Sie eine Netscape-Cookie-Datei (siehe yt-dlp FAQ) und wählen Sie sie hier bei Bedarf aus.",
"cookiesHelpFaq": "yt-dlp Cookie FAQ öffnen",
"openLinkError": "Link konnte nicht geöffnet werden",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "Konfigurationsdatei verwenden",
"configFileDescription": "Benutzerdefinierte Konfigurationsdatei für yt-dlp",
"clearConfigFile": "Löschen",
"dark": "Dunkel",
"description": "Konfigurieren Sie Ihre Download-Einstellungen und Anwendungseinstellungen",
"directorySelectError": "Verzeichnis konnte nicht ausgewählt werden",
"downloadPath": "Download-Speicherort",
"downloadPathDescription": "Wählen Sie, wo heruntergeladene Dateien gespeichert werden sollen",
"fileSelectError": "Datei konnte nicht ausgewählt werden",
"general": "Allgemein",
"language": "Sprache",
"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.",
"launchAtLogin": "Beim Start starten",
"launchAtLoginDescription": "VidBee automatisch öffnen, nachdem Sie sich bei Ihrem Computer angemeldet haben.",
"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.",
"maxConcurrentDownloads": "Maximale Anzahl aktiver Downloads",
"maxConcurrentDownloadsDescription": "Maximale Anzahl gleichzeitiger Downloads",
"none": "Keine",
"oneClickDownload": "Ein-Klick-Download",
"oneClickDownloadDescription": "Ein-Klick-Download mit Standardeinstellungen aktivieren",
"oneClickDownloadType": "Standard-Download-Typ",
"oneClickDownloadTypeDescription": "Wählen Sie den Standard-Download-Typ für Ein-Klick-Downloads. Die Qualität verwendet die unten stehende Voreinstellung.",
"oneClickQuality": "Bevorzugte Qualität",
"oneClickQualityDescription": "Wählen Sie die Qualitätsvoreinstellung für Ein-Klick-Downloads",
"oneClickQualityOptions": {
"auto": "Automatisch",
"bad": "Schlecht",
"best": "Am besten",
"good": "Gut",
"normal": "Normal",
"worst": "Am schlechtesten"
},
"proxy": "Proxy",
"proxyDescription": "Proxy-Server für Netzwerkanfragen",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Konfigurationsdatei auswählen",
"selectPath": "Auswählen",
"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)."
},
"system": "System",
"theme": "Design",
"themeDescription": "Wählen Sie ein helles, dunkles oder System-Design für VidBee",
"title": "Einstellungen",
"tray": {
"quit": "Beenden",
"showHome": "Startseite anzeigen"
},
"video": "Video-Einstellungen"
},
"subscriptions": {
"title": "Abonnements",
"subtitle": "{{count}} Abonnement{{count, plural, one {} other {e}}}",
"description": "Überwachen Sie RSS-Feeds automatisch und fügen Sie neue Downloads zur Warteschlange hinzu, ohne manuelle Arbeit.",
"defaults": {
"title": "Automatisierungs-Standardeinstellungen",
"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."
},
"add": {
"title": "RSS hinzufügen",
"description": "Fügen Sie einen RSS-Feed-Link ein. VidBee erkennt den Feed automatisch."
},
"fields": {
"url": "Feed-URL",
"keywords": "Schlüsselwortfilter (durch Komma getrennt)",
"tags": "Automatische Tags",
"customDirectory": "Benutzerdefiniertes Verzeichnis",
"namingTemplate": "Benutzerdefinierte Dateinamen-Vorlage (nur Datei)",
"onlyLatest": "Nur das neueste Video herunterladen",
"onlyLatestDescription": "Backlog-Elemente ignorieren und nur den neuesten Upload aus diesem Feed abrufen.",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"onlyLatestShort": "Nur neueste"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Hinzufügen",
"refresh": "Aktualisieren",
"edit": "Bearbeiten",
"remove": "Entfernen",
"save": "Änderungen speichern",
"selectDirectory": "Durchsuchen",
"enable": "Aktivieren",
"disable": "Deaktivieren"
},
"items": {
"title": "Neueste Uploads ({{count}})",
"count": "{{count}} Elemente",
"empty": "Keine kürzlichen Feed-Elemente gefunden.",
"status": {
"queued": "In Warteschlange",
"notQueued": "Nicht in Warteschlange",
"pending": "Ausstehend",
"downloading": "Wird heruntergeladen",
"processing": "Wird verarbeitet",
"completed": "Abgeschlossen",
"error": "Fehlgeschlagen",
"cancelled": "Abgebrochen"
},
"fromChannel": "Von {{channel}}",
"tooltip": {
"downloadStatus": "Download-Status: {{status}}",
"downloadPending": "Warten auf Download-Details...",
"notQueued": "Noch nicht in der Download-Warteschlange"
},
"actions": {
"open": "Im Browser öffnen",
"queue": "Zur Download-Warteschlange hinzufügen"
}
},
"labels": {
"subscription": "Abonnement",
"unknown": "Unbekanntes Abonnement",
"noThumbnail": "Kein Vorschaubild"
},
"notifications": {
"directoryError": "Verzeichnisauswahl konnte nicht geöffnet werden.",
"missingUrl": "Bitte fügen Sie zuerst einen Kanal-Link ein.",
"created": "Abonnement hinzugefügt",
"createError": "Abonnement konnte nicht hinzugefügt werden.",
"refreshStarted": "Aktualisierung gestartet",
"removed": "Abonnement entfernt",
"updated": "Abonnement aktualisiert",
"itemQueued": "Zur Download-Warteschlange hinzugefügt",
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden."
},
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
"detecting": "Feed wird erkannt...",
"latestVideo": "Neuestes Video: {{title}}",
"lastChecked": "Zuletzt geprüft: {{time}}",
"never": "Nie",
"empty": "Noch keine Abonnements. Fügen Sie Ihre Lieblingskanäle hinzu, um mit dem automatischen Download zu beginnen.",
"edit": {
"title": "{{name}} bearbeiten",
"description": "Passen Sie Filter, Tags und Überschreibungen für diesen Feed an."
},
"status": {
"title": "Status",
"up-to-date": "Aktuell",
"checking": "Wird geprüft",
"failed": "Fehlgeschlagen",
"idle": "Leerlauf",
"tooltip": {
"updatedAt": "Aktualisiert: {{time}}"
}
},
"rssHub": {
"title": "Automatische Abonnements mit RSSHub",
"description": "Kombinieren Sie VidBee mit RSSHub, um automatische Abonnements und Downloads von verschiedenen Plattformen zu ermöglichen. Nach der Einrichtung läuft VidBee im Hintergrund und lädt automatisch die neuesten Videos und Inhalte herunter.",
"learnMore": "Mehr über RSSHub erfahren",
"openDocs": "RSSHub-Dokumentation öffnen",
"hint": "Keine RSS-Feed-URL? Verwenden Sie RSSHub, um RSS-Feeds für YouTube, Twitter und Tausende anderer Plattformen zu generieren."
}
},
"sites": {
"homeInlineDescription": "Unterstützt {{sites}} und mehr.",
"moreDescription": "Die vollständige yt-dlp-Liste wird ständig von der Community aktualisiert.",
"moreTitle": "Benötigen Sie eine andere Website?",
"openFullList": "Vollständige Liste der unterstützten Websites öffnen",
"pageDescription": "VidBee verwendet yt-dlp unter der Haube, um Hunderte von Quellen zu erreichen.",
"pageIntro": "Hier sind die gängigen Dienste, von denen die meisten Menschen herunterladen.",
"pageTitle": "Unterstützte Websites",
"popular": {
"bandcamp": {
"description": "Alben unabhängiger Künstler und Community-Veröffentlichungen.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Globale Nachrichten, Sport und Unterhaltungsclips.",
"label": "Dailymotion"
},
"facebook": {
"description": "Videos aus Feed, Watch und Reels von öffentlichen Seiten.",
"label": "Facebook"
},
"instagram": {
"description": "Inhalte aus Feed, Stories, Reels und Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Live-Streams und Wiederholungen von Creators auf der Kick-Plattform.",
"label": "Kick"
},
"linkedin": {
"description": "Professionelle Vorträge, Webinare und Lernvideos.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ-Mixe, Radiosendungen und lange Audioformate.",
"label": "Mixcloud"
},
"niconico": {
"description": "Japanische Animation, Musik und Live-Übertragungsarchiv.",
"label": "Niconico"
},
"pinterest": {
"description": "Ideen-Pins, How-to-Reels und Lifestyle-Inspirationsvideos.",
"label": "Pinterest"
},
"reddit": {
"description": "Eingebettete Clips und gehostete Videos aus Communities.",
"label": "Reddit"
},
"soundcloud": {
"description": "Musiktitel, Playlists und DJ-Sets.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Kurze mobile Videos, Effekte und Live-Streams.",
"label": "TikTok"
},
"tumblr": {
"description": "Kreative kurze Medien und Fan-Bearbeitungen.",
"label": "Tumblr"
},
"twitch": {
"description": "Gaming-, Musik- und IRL-Live-Streams und VODs.",
"label": "Twitch"
},
"twitter": {
"description": "Timeline-Posts, Spaces-Aufnahmen und Übertragungen.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Hochwertiges Video-Hosting für Creators und Unternehmen.",
"label": "Vimeo"
},
"youtube": {
"description": "Lange Videos und Livestreams von Creators weltweit.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Offizielle Musikvideos, Alben und Live-Auftritte.",
"label": "YouTube Music"
}
},
"popularSection": "Hauptplattformen",
"viewAll": "Alle unterstützten Websites anzeigen"
}
}

View File

@@ -2,8 +2,10 @@
"about": {
"actions": {
"checkUpdates": "Check updates",
"download": "Download",
"email": "Email",
"feedback": "Feedback",
"goToDownload": "Go to download page",
"openRepo": "Open GitHub repository",
"view": "View",
"visit": "Visit"
@@ -14,18 +16,29 @@
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
"followAuthorActions": {
"follow": "Follow @nexmoex"
},
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
"followAuthorTitle": "Follow the Developer",
"here": "here",
"homepage": "Homepage",
"notifications": {
"checkingUpdates": "Looking for updates...",
"updateAvailable": "Update available: {{version}}",
"noUpdatesAvailable": "You're using the latest version",
"updateError": "Failed to check for updates: {{error}}",
"downloadUpdate": "Download and install update {{version}}?",
"downloadStarted": "Download started...",
"downloadError": "Failed to download update",
"downloadStarted": "Download started...",
"downloadUpdate": "Download and install update {{version}}?",
"manualDownloadAction": "Download now",
"noUpdatesAvailable": "You're using the latest version",
"restartToUpdate": "Restart now to install update?",
"restartNowAction": "Restart now",
"updateAvailable": "Update available: {{version}}",
"updateAvailableMessage": "A new version {{version}} is available. Please download it from the official website.",
"updateDownloaded": "Update downloaded, restart to install",
"restartToUpdate": "Restart now to install update?"
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
"updateError": "Failed to check for updates: {{error}}",
"unknownErrorFallback": "Unknown error"
},
"preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles",
@@ -45,19 +58,25 @@
},
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
"resourcesTitle": "Resources",
"shareActions": {
"copy": "Copy link",
"facebook": "Share on Facebook",
"twitter": "Share on X (Twitter)"
},
"shareDescription": "Share VidBee with your community in one click.",
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word",
"shareDescription": "Share VidBee with your community in one click.",
"shareActions": {
"twitter": "Share on X (Twitter)",
"facebook": "Share on Facebook",
"copy": "Copy link"
},
"sourceCode": "Source Code is available",
"tagline": "An AI-friendly download helper for every creator",
"title": "About",
"version": "Version",
"versionLabel": "v{{version}}"
"versionLabel": "v{{version}}",
"latestVersionBadge": "Latest: v{{version}}",
"latestVersionStatus": {
"available": "New version available",
"uptodate": "You're up to date",
"error": "Unable to fetch the latest version"
},
"downloadingUpdate": "Downloading update"
},
"advancedOptions": {
"closeWhenDone": "Close app when download finishes",
@@ -91,7 +110,7 @@
"worst": "Worst"
},
"download": {
"active": "active",
"active": "Active",
"all": "All",
"audio": "Audio",
"back": "Back",
@@ -121,8 +140,10 @@
"noAudio": "No Audio",
"noHistory": "No download history",
"noItems": "No items found",
"goToSettings": "Go to Settings",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
"oneClickDownloadNow": "Download Now",
"oneClickDownloadStarted": "Download started with default settings",
"paste": "Paste",
@@ -131,6 +152,8 @@
"preparing": "Preparing...",
"processing": "Processing",
"progress": "Progress",
"showDetails": "Show details",
"hideDetails": "Hide details",
"selectAudioFormat": "Select Audio Format",
"selectFormat": "Select Format",
"selectVideoFormat": "Select Video Format",
@@ -143,7 +166,33 @@
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Video Information",
"videoInfoUpdated": "Video information updated"
"videoInfoUpdated": "Video information updated",
"metadata": {
"source": "Source",
"playlist": "Playlist",
"format": "Format",
"quality": "Quality",
"codec": "Codec",
"savedFile": "Saved file",
"url": "Source URL",
"description": "Description",
"views": "Views",
"tags": "Tags",
"downloadPath": "Download path",
"createdAt": "Created at",
"startedAt": "Started at",
"completedAt": "Completed at",
"speed": "Speed",
"fileSize": "File size",
"width": "Width",
"height": "Height",
"fps": "FPS",
"videoCodec": "Video codec",
"audioCodec": "Audio codec",
"formatNote": "Format note",
"protocol": "Protocol",
"subscription": "Subscription"
}
},
"errors": {
"clickToCopy": "Click to copy details",
@@ -160,8 +209,8 @@
"clearCancelled": "Clear Cancelled",
"clearCompleted": "Clear Completed",
"clearErrors": "Clear Errors",
"copyToClipboard": "Copy to clipboard",
"copyUrl": "Copy URL",
"openInBrowser": "Click to open in browser",
"date": "Date",
"description": "View and manage your download history",
"duration": "Duration",
@@ -174,11 +223,11 @@
},
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openDownloadFolder": "Open Download Folder",
"openFile": "Open File",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
"outputPath": "Output Path",
"openInBrowser": "Click to open in browser",
"removeItem": "Remove Item",
"stats": {
"cancelled": "Cancelled",
@@ -197,6 +246,8 @@
"about": "About",
"download": "Download",
"playlist": "Download Playlist",
"rss": "RSS",
"subscriptions": "Subscriptions",
"preferences": "Preferences",
"supportedSites": "Supported Sites",
"theme": "Theme:"
@@ -211,9 +262,12 @@
"openFolderFailed": "Failed to open folder",
"removeFailed": "Failed to remove item",
"settingsSaved": "Settings saved",
"urlCopied": "URL copied to clipboard"
"urlCopied": "URL copied to clipboard",
"videoCopied": "Video copied to clipboard"
},
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Clear preview",
"comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel",
@@ -228,12 +282,27 @@
"filenameFormat": "Filename format for playlists",
"folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist",
"groupActive": "{{count}} active",
"groupErrors": "{{count}} failed",
"groupSummary": "{{completed}} / {{total}} completed",
"linkLabel": "Playlist URL",
"noEntries": "No videos were found in this playlist",
"noEntriesInRange": "No videos in the selected range",
"noRangeSelected": "No end set - full playlist selected",
"playlistUrlDescription": "Download all videos from a playlist in bulk",
"positionLabel": "Item {{index}} of {{total}}",
"previewButton": "Preview playlist",
"previewFailed": "Failed to preview playlist",
"previewSummary": "Preview playlist items before downloading.",
"previewRequired": "Preview the playlist before downloading.",
"range": "Range (Optional)",
"resetToDefault": "Reset to default",
"selectedRange": "Range: {{start}}-{{end}}",
"showingCount": "Showing {{count}} videos",
"startIndex": "Start (1)",
"title": "Download Playlist"
"title": "Download Playlist",
"totalVideos": "Total videos: {{count}}",
"untitled": "Untitled playlist"
},
"settings": {
"aboutTab": "About",
@@ -241,23 +310,51 @@
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
"cookiesFile": "Cookies file",
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
"clearCookiesFile": "Clear",
"cookiesHelpTitle": "Using cookies",
"cookiesHelpBrowser": "Pick your browser above to reuse its signed-in session automatically.",
"cookiesHelpFile": "Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.",
"cookiesHelpFaq": "Open yt-dlp cookies FAQ",
"openLinkError": "Failed to open link",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "Use configuration file",
"configFileDescription": "Custom configuration file for yt-dlp",
"clearConfigFile": "Clear",
"dark": "Dark",
"description": "Configure your download preferences and application settings",
"directorySelectError": "Failed to select directory",
"downloadPath": "Download location",
"downloadPathDescription": "Choose where to save downloaded files",
"fileSelectError": "Failed to select file",
"general": "General",
"language": "Language",
"languageOptions": {
"chinese": "Chinese (Simplified)",
"english": "English"
},
"light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
"launchAtLogin": "Launch at startup",
"launchAtLoginDescription": "Open VidBee automatically after you sign in to your computer.",
"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.",
"maxConcurrentDownloads": "Maximum number of active downloads",
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
"none": "None",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Enable one-click download with default settings",
"oneClickDownloadType": "Default download type",
"oneClickDownloadTypeDescription": "Choose the default download type for one-click downloads. Quality uses the preset below.",
"oneClickQuality": "Preferred quality",
"oneClickQualityDescription": "Select the quality preset used for one-click downloads",
"oneClickQualityOptions": {
"auto": "Auto",
"bad": "Bad",
@@ -267,11 +364,19 @@
"worst": "Worst"
},
"proxy": "Proxy",
"proxyDescription": "Proxy server for network requests",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Select config file",
"selectPath": "Select",
"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)."
},
"system": "System",
"theme": "Theme",
"themeDescription": "Choose a light, dark, or system theme for VidBee",
"title": "Settings",
"tray": {
"quit": "Quit",
@@ -279,6 +384,120 @@
},
"video": "Video Preferences"
},
"subscriptions": {
"title": "Subscriptions",
"subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}",
"description": "Automatically monitor RSS feeds and queue new downloads without manual work.",
"defaults": {
"title": "Automation defaults",
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
"downloadDirectory": "Download directory",
"filenameTemplate": "Filename template (file only)",
"checkInterval": "Check interval (hours)",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
},
"add": {
"title": "Add RSS",
"description": "Paste an RSS feed link. VidBee will detect the feed automatically."
},
"fields": {
"url": "Feed URL",
"keywords": "Keyword filter (comma separated)",
"tags": "Auto tags",
"customDirectory": "Custom directory",
"namingTemplate": "Custom filename template (file only)",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
"enabled": "Enabled",
"disabled": "Disabled",
"onlyLatestShort": "Only latest"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Add",
"refresh": "Refresh",
"edit": "Edit",
"remove": "Remove",
"save": "Save changes",
"selectDirectory": "Browse",
"enable": "Enable",
"disable": "Disable"
},
"items": {
"title": "Latest uploads ({{count}})",
"count": "{{count}} items",
"empty": "No recent feed items found.",
"status": {
"queued": "Queued",
"notQueued": "Not queued",
"pending": "Pending",
"downloading": "Downloading",
"processing": "Processing",
"completed": "Completed",
"error": "Failed",
"cancelled": "Cancelled"
},
"fromChannel": "From {{channel}}",
"tooltip": {
"downloadStatus": "Download status: {{status}}",
"downloadPending": "Waiting for download details...",
"notQueued": "Not in the download queue yet"
},
"actions": {
"open": "Open in browser",
"queue": "Add to download queue"
}
},
"labels": {
"subscription": "Subscription",
"unknown": "Unknown subscription",
"noThumbnail": "No thumbnail"
},
"notifications": {
"directoryError": "Failed to open the directory picker.",
"missingUrl": "Please paste a channel link first.",
"created": "Subscription added",
"createError": "Failed to add subscription.",
"refreshStarted": "Refresh started",
"removed": "Subscription removed",
"updated": "Subscription updated",
"itemQueued": "Added to download queue",
"itemAlreadyQueued": "This video is already queued",
"queueError": "Failed to add to download queue.",
"openLinkError": "Failed to open the video link.",
"resolveError": "Failed to resolve RSS feed URL."
},
"detectedFeed": "Detected {{platform}} feed -> {{feed}}",
"detecting": "Detecting feed...",
"latestVideo": "Latest video: {{title}}",
"lastChecked": "Last checked: {{time}}",
"never": "Never",
"empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.",
"edit": {
"title": "Edit {{name}}",
"description": "Tweak filters, tags, and overrides for this feed."
},
"status": {
"title": "Status",
"up-to-date": "Up to date",
"checking": "Checking",
"failed": "Failed",
"idle": "Idle",
"tooltip": {
"updatedAt": "Updated: {{time}}"
}
},
"rssHub": {
"title": "Automated Subscriptions with RSSHub",
"description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.",
"learnMore": "Learn more about RSSHub",
"openDocs": "Open RSSHub Documentation",
"hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms."
}
},
"sites": {
"homeInlineDescription": "Supports {{sites}} and more.",
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
@@ -287,6 +506,80 @@
"pageDescription": "VidBee uses yt-dlp under the hood to reach hundreds of sources.",
"pageIntro": "Here are the mainstream services people download from most frequently.",
"pageTitle": "Supported Sites",
"popular": {
"bandcamp": {
"description": "Independent artist albums and community releases.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Global news, sports, and entertainment clips.",
"label": "Dailymotion"
},
"facebook": {
"description": "Feed, Watch, and Reels videos from public pages.",
"label": "Facebook"
},
"instagram": {
"description": "Feed, Stories, Reels, and Highlights content.",
"label": "Instagram"
},
"kick": {
"description": "Creator live streams and replays on the Kick platform.",
"label": "Kick"
},
"linkedin": {
"description": "Professional talks, webinars, and learning videos.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ mixes, radio shows, and long-form audio.",
"label": "Mixcloud"
},
"niconico": {
"description": "Japanese animation, music, and live broadcast archive.",
"label": "Niconico"
},
"pinterest": {
"description": "Idea pins, how-to reels, and lifestyle inspiration videos.",
"label": "Pinterest"
},
"reddit": {
"description": "Embedded clips and hosted videos from communities.",
"label": "Reddit"
},
"soundcloud": {
"description": "Music tracks, playlists, and DJ sets.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Short-form mobile videos, effects, and live streams.",
"label": "TikTok"
},
"tumblr": {
"description": "Creative short-form media and fan edits.",
"label": "Tumblr"
},
"twitch": {
"description": "Gaming, music, and IRL live streams and VODs.",
"label": "Twitch"
},
"twitter": {
"description": "Timeline posts, Spaces recordings, and broadcasts.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "High-quality creator and business video hosting.",
"label": "Vimeo"
},
"youtube": {
"description": "Long-form and livestream video from creators worldwide.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Official music videos, albums, and live performances.",
"label": "YouTube Music"
}
},
"popularSection": "Main platforms",
"viewAll": "View all supported sites"
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Verificar actualizaciones",
"download": "Descargar",
"email": "Correo electrónico",
"feedback": "Comentarios",
"goToDownload": "Ir a la página de descarga",
"openRepo": "Abrir repositorio de GitHub",
"view": "Ver",
"visit": "Visitar"
},
"appName": "VidBee",
"autoUpdateDescription": "Descargar e instalar nuevas versiones automáticamente en segundo plano.",
"autoUpdateTitle": "Actualizaciones automáticas",
"betaProgramDescription": "Recibe compilaciones tempranas y próximas funciones antes que nadie.",
"betaProgramTitle": "Canal de vista previa",
"description": "VidBee es un descargador gratuito y de código abierto construido con Electron y potenciado por yt-dlp.",
"followAuthorActions": {
"follow": "Seguir a @nexmoex"
},
"followAuthorDescription": "Mantente actualizado con las últimas noticias y actualizaciones de VidBee.",
"followAuthorSupport": "Sigue al desarrollador en X (Twitter) para obtener las últimas actualizaciones y noticias sobre VidBee.",
"followAuthorTitle": "Seguir al Desarrollador",
"here": "aquí",
"homepage": "Página de inicio",
"notifications": {
"checkingUpdates": "Buscando actualizaciones...",
"downloadError": "Error al descargar la actualización",
"downloadStarted": "Descarga iniciada...",
"downloadUpdate": "¿Descargar e instalar la actualización {{version}}?",
"manualDownloadAction": "Descargar ahora",
"noUpdatesAvailable": "Estás usando la última versión",
"restartToUpdate": "¿Reiniciar ahora para instalar la actualización?",
"restartNowAction": "Reiniciar ahora",
"updateAvailable": "Actualización disponible: {{version}}",
"updateAvailableMessage": "Una nueva versión {{version}} está disponible. Por favor, descárgala desde el sitio web oficial.",
"updateDownloaded": "Actualización descargada, reinicia para instalar",
"updateDownloadedVersion": "Actualización {{version}} descargada, reinicia para instalar",
"updateError": "Error al verificar actualizaciones: {{error}}",
"unknownErrorFallback": "Error desconocido"
},
"preferencesDescription": "Ajusta la configuración de actualizaciones sin salir de esta página.",
"preferencesTitle": "Cambios Rápidos",
"resources": {
"changelog": "Notas de la versión",
"changelogDescription": "Infórmate sobre lo que cambió en cada versión.",
"contact": "Soporte por correo",
"contactDescription": "Contacta directamente para ayuda o colaboración.",
"documentation": "Centro de ayuda",
"documentationDescription": "Guías, preguntas frecuentes y flujos de trabajo comunes.",
"feedback": "Comentarios e incidencias",
"feedbackDescription": "Comparte ideas o reporta problemas en GitHub.",
"license": "Licencia",
"licenseDescription": "Revisa los términos de la licencia de código abierto.",
"website": "Sitio web oficial",
"websiteDescription": "Destacados del producto, hoja de ruta y noticias de la comunidad."
},
"resourcesDescription": "Enlaces útiles para aprender más sobre VidBee y mantenerte conectado.",
"resourcesTitle": "Recursos",
"shareActions": {
"copy": "Copiar enlace",
"facebook": "Compartir en Facebook",
"twitter": "Compartir en X (Twitter)"
},
"shareDescription": "Comparte VidBee con tu comunidad con un clic.",
"shareSupport": "Recomienda VidBee a tus amigos para apoyar nuestro crecimiento y actualizaciones.",
"shareTitle": "Difunde la palabra",
"sourceCode": "El código fuente está disponible",
"title": "Acerca de",
"version": "Versión",
"versionLabel": "v{{version}}",
"latestVersionBadge": "Última: v{{version}}",
"latestVersionStatus": {
"available": "Nueva versión disponible",
"uptodate": "Estás actualizado",
"error": "No se pudo obtener la última versión"
},
"downloadingUpdate": "Descargando actualización"
},
"advancedOptions": {
"closeWhenDone": "Cerrar la aplicación cuando termine la descarga",
"currentLocation": "Ubicación de descarga actual - ",
"downloadLocation": "Ubicación de descarga",
"downloadSubs": "Descargar subtítulos si están disponibles",
"end": "Fin",
"endHint": "Si se deja vacío, se descargará hasta el final",
"endPlaceholder": "10:00",
"selectLocation": "Seleccionar Ubicación de Descarga",
"start": "Inicio",
"startHint": "Si se deja vacío, comenzará desde el principio",
"startPlaceholder": "00:00",
"subtitles": "Subtítulos",
"timeRange": "Descargar rango de tiempo específico",
"title": "Opciones Avanzadas"
},
"app": {
"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",
"audio": "Audio",
"back": "Atrás",
"cancel": "Cancelar",
"cancelled": "Cancelado",
"clearCompleted": "Limpiar Completados",
"clearDownloads": "Limpiar Descargas",
"completed": "Completado",
"downloadAudio": "Descargar Audio",
"downloadBtn": "Descargar",
"downloadPending": "Pendiente",
"downloadQueue": "Cola de Descarga",
"downloadVideo": "Descargar Video",
"downloading": "Descargando...",
"enterUrl": "Ingresar URL del Video",
"enterUrlDescription": "Pega o escribe una URL de video. ",
"error": "Error",
"fetch": "Obtener",
"fetchingVideoInfo": "Obteniendo información del video...",
"history": "Historial",
"imageLoadError": "Error al cargar la imagen",
"imagePlaceholder": "No hay imagen disponible",
"infoUnavailable": "Descarga con un clic (Información no disponible)",
"loading": "Cargando",
"moreOptions": "Más opciones",
"noActiveDownloads": "No hay descargas activas",
"noAudio": "Sin Audio",
"noHistory": "No hay historial de descargas",
"noItems": "No se encontraron elementos",
"goToSettings": "Ir a Configuración",
"oneClickDownload": "Descarga con un Clic",
"oneClickDownloadDescription": "Descargar directamente con la configuración predeterminada sin confirmación",
"oneClickDownloadEnabled": "La descarga con un clic está habilitada. Las descargas comenzarán directamente con la configuración predeterminada.",
"oneClickDownloadNow": "Descargar Ahora",
"oneClickDownloadStarted": "Descarga iniciada con la configuración predeterminada",
"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]",
"preparing": "Preparando...",
"processing": "Procesando",
"progress": "Progreso",
"showDetails": "Mostrar detalles",
"hideDetails": "Ocultar detalles",
"selectAudioFormat": "Seleccionar Formato de Audio",
"selectFormat": "Seleccionar Formato",
"selectVideoFormat": "Seleccionar Formato de Video",
"singleVideo": "Video Individual",
"speed": "Velocidad",
"title": "Título",
"total": "Total",
"unknownQuality": "Calidad desconocida",
"unknownSize": "Tamaño desconocido",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Información del Video",
"videoInfoUpdated": "Información del video actualizada",
"metadata": {
"source": "Fuente",
"playlist": "Lista de reproducción",
"format": "Formato",
"quality": "Calidad",
"codec": "Códec",
"savedFile": "Archivo guardado",
"url": "URL de origen",
"description": "Descripción",
"views": "Visualizaciones",
"tags": "Etiquetas",
"downloadPath": "Ruta de descarga",
"createdAt": "Creado en",
"startedAt": "Iniciado en",
"completedAt": "Completado en",
"speed": "Velocidad",
"fileSize": "Tamaño del archivo",
"width": "Ancho",
"height": "Alto",
"fps": "FPS",
"videoCodec": "Códec de video",
"audioCodec": "Códec de audio",
"formatNote": "Nota de formato",
"protocol": "Protocolo",
"subscription": "Suscripción"
}
},
"errors": {
"clickToCopy": "Haz clic para copiar los detalles",
"clipboardEmpty": "El portapapeles está vacío",
"downloadFailed": "Error en la descarga",
"downloadNecessaryFilesFailed": "Error al descargar archivos necesarios. Por favor, verifica tu red e intenta de nuevo",
"emptyUrl": "Por favor, ingresa una URL",
"errorDetails": "Detalles del Error",
"fetchInfoFailed": "Error al obtener información del video",
"networkError": "Ha ocurrido un error. Verifica tu red y usa una URL correcta",
"pasteFromClipboard": "Error al pegar desde el portapapeles"
},
"history": {
"clearCancelled": "Limpiar Cancelados",
"clearCompleted": "Limpiar Completados",
"clearErrors": "Limpiar Errores",
"copyToClipboard": "Copiar al portapapeles",
"copyUrl": "Copiar URL",
"date": "Fecha",
"description": "Ver y gestionar tu historial de descargas",
"duration": "Duración",
"fileSize": "Tamaño del Archivo",
"filters": {
"all": "Todo",
"cancelled": "Cancelado",
"completed": "Completado",
"errors": "Errores"
},
"noHistory": "Aún no hay historial de descargas",
"noHistoryDescription": "Tus descargas completadas aparecerán aquí",
"openDownloadFolder": "Abrir Carpeta de Descargas",
"openFile": "Abrir Archivo",
"openFileLocation": "Abrir Ubicación del Archivo",
"openFolder": "Abrir Carpeta",
"openInBrowser": "Haz clic para abrir en el navegador",
"removeItem": "Eliminar Elemento",
"stats": {
"cancelled": "Cancelado",
"completed": "Completado",
"errors": "Errores",
"total": "Total"
},
"status": {
"cancelled": "Cancelado",
"completed": "Completado",
"error": "Error"
},
"title": "Historial de Descargas"
},
"menu": {
"about": "Acerca de",
"download": "Descargar",
"playlist": "Descargar Lista de Reproducción",
"rss": "RSS",
"subscriptions": "Suscripciones",
"preferences": "Preferencias",
"supportedSites": "Sitios Soportados",
"theme": "Tema:"
},
"notifications": {
"copyFailed": "Error al copiar al portapapeles",
"downloadCompleted": "Descarga completada",
"downloadFailed": "Error en la descarga",
"downloadStarted": "Descarga iniciada",
"itemRemoved": "Elemento eliminado",
"openFileFailed": "Error al abrir el archivo",
"openFolderFailed": "Error al abrir la carpeta",
"removeFailed": "Error al eliminar el elemento",
"settingsSaved": "Configuración guardada",
"urlCopied": "URL copiada al portapapeles",
"videoCopied": "Video copiado al portapapeles"
},
"playlist": {
"badgeLabel": "Lista de reproducción",
"clearPreview": "Limpiar vista previa",
"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",
"downloadFailed": "Error al iniciar la descarga de la lista de reproducción",
"downloadPlaylist": "Descargar Lista de Reproducción",
"downloadStarted": "Comenzó la descarga de {{count}} videos de la lista de reproducción",
"downloadType": "Tipo de Descarga",
"downloading": "Descargando lista de reproducción:",
"endIndex": "Fin",
"enterPlaylistUrl": "Ingresar URL de la Lista de Reproducción",
"fetchFailed": "Error al obtener información de la lista de reproducción",
"filenameFormat": "Formato de nombre de archivo para listas de reproducción",
"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",
"groupErrors": "{{count}} fallido",
"groupSummary": "{{completed}} / {{total}} completado",
"linkLabel": "URL de la Lista de Reproducción",
"noEntries": "No se encontraron videos en esta lista de reproducción",
"noEntriesInRange": "No hay videos en el rango seleccionado",
"noRangeSelected": "No se estableció fin - lista de reproducción completa seleccionada",
"playlistUrlDescription": "Descarga todos los videos de una lista de reproducción en masa",
"positionLabel": "Elemento {{index}} de {{total}}",
"previewButton": "Vista previa de la lista de reproducción",
"previewFailed": "Error al obtener vista previa de la lista de reproducción",
"previewSummary": "Vista previa de los elementos de la lista de reproducción antes de descargar.",
"previewRequired": "Vista previa de la lista de reproducción antes de descargar.",
"range": "Rango (Opcional)",
"resetToDefault": "Restablecer a predeterminado",
"selectedRange": "Rango: {{start}}-{{end}}",
"showingCount": "Mostrando {{count}} videos",
"startIndex": "Inicio (1)",
"title": "Descargar Lista de Reproducción",
"totalVideos": "Total de videos: {{count}}",
"untitled": "Lista de reproducción sin título"
},
"settings": {
"aboutTab": "Acerca de",
"advanced": "Avanzado",
"app": "Configuración de la Aplicación",
"audio": "Preferencias de Audio",
"browserForCookies": "Seleccionar navegador para usar cookies",
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
"cookiesFile": "Archivo de cookies",
"cookiesFileDescription": "Archivo de cookies con formato Netscape para cargar para autenticación",
"clearCookiesFile": "Limpiar",
"cookiesHelpTitle": "Usar cookies",
"cookiesHelpBrowser": "Elige tu navegador arriba para reutilizar automáticamente su sesión iniciada.",
"cookiesHelpFile": "Exporta un archivo de cookies Netscape (consulta las preguntas frecuentes de yt-dlp) y selecciónalo aquí cuando sea necesario.",
"cookiesHelpFaq": "Abrir preguntas frecuentes de cookies de yt-dlp",
"openLinkError": "Error al abrir el enlace",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "Usar archivo de configuración",
"configFileDescription": "Archivo de configuración personalizado para yt-dlp",
"clearConfigFile": "Limpiar",
"dark": "Oscuro",
"description": "Configura tus preferencias de descarga y configuración de la aplicación",
"directorySelectError": "Error al seleccionar directorio",
"downloadPath": "Ubicación de descarga",
"downloadPathDescription": "Elige dónde guardar los archivos descargados",
"fileSelectError": "Error al seleccionar archivo",
"general": "General",
"language": "Idioma",
"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.",
"launchAtLogin": "Iniciar al arrancar",
"launchAtLoginDescription": "Abrir VidBee automáticamente después de iniciar sesión en tu computadora.",
"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.",
"maxConcurrentDownloads": "Número máximo de descargas activas",
"maxConcurrentDownloadsDescription": "Número máximo de descargas simultáneas",
"none": "Ninguno",
"oneClickDownload": "Descarga con un Clic",
"oneClickDownloadDescription": "Habilitar descarga con un clic con configuración predeterminada",
"oneClickDownloadType": "Tipo de descarga predeterminado",
"oneClickDownloadTypeDescription": "Elige el tipo de descarga predeterminado para descargas con un clic. La calidad usa el ajuste preestablecido a continuación.",
"oneClickQuality": "Calidad preferida",
"oneClickQualityDescription": "Selecciona el ajuste preestablecido de calidad usado para descargas con un clic",
"oneClickQualityOptions": {
"auto": "Automático",
"bad": "Malo",
"best": "Mejor",
"good": "Bueno",
"normal": "Normal",
"worst": "Peor"
},
"proxy": "Proxy",
"proxyDescription": "Servidor proxy para solicitudes de red",
"proxyPlaceholder": "http://proxy:puerto",
"selectConfigFile": "Seleccionar archivo de configuración",
"selectPath": "Seleccionar",
"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)."
},
"system": "Sistema",
"theme": "Tema",
"themeDescription": "Elige un tema claro, oscuro o del sistema para VidBee",
"title": "Configuración",
"tray": {
"quit": "Salir",
"showHome": "Mostrar Inicio"
},
"video": "Preferencias de Video"
},
"subscriptions": {
"title": "Suscripciones",
"subtitle": "{{count}} suscripción{{count, plural, one {} other {s}}}",
"description": "Monitorea automáticamente los feeds RSS y encola nuevas descargas sin trabajo manual.",
"defaults": {
"title": "Valores predeterminados de automatización",
"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."
},
"add": {
"title": "Agregar RSS",
"description": "Pega un enlace de feed RSS. VidBee detectará el feed automáticamente."
},
"fields": {
"url": "URL del Feed",
"keywords": "Filtro de palabras clave (separadas por comas)",
"tags": "Etiquetas automáticas",
"customDirectory": "Directorio personalizado",
"namingTemplate": "Plantilla de nombre de archivo personalizada (solo archivo)",
"onlyLatest": "Descargar solo el último video",
"onlyLatestDescription": "Ignorar elementos del backlog y obtener solo la última carga de este feed.",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"onlyLatestShort": "Solo último"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Agregar",
"refresh": "Actualizar",
"edit": "Editar",
"remove": "Eliminar",
"save": "Guardar cambios",
"selectDirectory": "Explorar",
"enable": "Habilitar",
"disable": "Deshabilitar"
},
"items": {
"title": "Últimas cargas ({{count}})",
"count": "{{count}} elementos",
"empty": "No se encontraron elementos recientes del feed.",
"status": {
"queued": "En cola",
"notQueued": "No en cola",
"pending": "Pendiente",
"downloading": "Descargando",
"processing": "Procesando",
"completed": "Completado",
"error": "Fallido",
"cancelled": "Cancelado"
},
"fromChannel": "De {{channel}}",
"tooltip": {
"downloadStatus": "Estado de descarga: {{status}}",
"downloadPending": "Esperando detalles de descarga...",
"notQueued": "Aún no está en la cola de descarga"
},
"actions": {
"open": "Abrir en el navegador",
"queue": "Agregar a la cola de descarga"
}
},
"labels": {
"subscription": "Suscripción",
"unknown": "Suscripción desconocida",
"noThumbnail": "Sin miniatura"
},
"notifications": {
"directoryError": "Error al abrir el selector de directorio.",
"missingUrl": "Por favor, pega primero un enlace de canal.",
"created": "Suscripción agregada",
"createError": "Error al agregar suscripción.",
"refreshStarted": "Actualización iniciada",
"removed": "Suscripción eliminada",
"updated": "Suscripción actualizada",
"itemQueued": "Agregado a la cola de descarga",
"itemAlreadyQueued": "Este video ya está en cola",
"queueError": "Error al agregar a la cola de descarga.",
"openLinkError": "Error al abrir el enlace del video.",
"resolveError": "Error al resolver la URL del feed RSS."
},
"detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
"detecting": "Detectando feed...",
"latestVideo": "Último video: {{title}}",
"lastChecked": "Última verificación: {{time}}",
"never": "Nunca",
"empty": "Aún no hay suscripciones. Agrega tus canales favoritos para comenzar a descargar automáticamente.",
"edit": {
"title": "Editar {{name}}",
"description": "Ajusta filtros, etiquetas y sobrescrituras para este feed."
},
"status": {
"title": "Estado",
"up-to-date": "Actualizado",
"checking": "Verificando",
"failed": "Fallido",
"idle": "Inactivo",
"tooltip": {
"updatedAt": "Actualizado: {{time}}"
}
},
"rssHub": {
"title": "Suscripciones Automatizadas con RSSHub",
"description": "Combina VidBee con RSSHub para habilitar suscripciones y descargas automatizadas de varias plataformas. Una vez configurado, VidBee se ejecuta en segundo plano y descarga automáticamente los últimos videos y contenido.",
"learnMore": "Aprende más sobre RSSHub",
"openDocs": "Abrir Documentación de RSSHub",
"hint": "¿No tienes una URL de feed RSS? Usa RSSHub para generar feeds RSS para YouTube, Twitter y miles de otras plataformas."
}
},
"sites": {
"homeInlineDescription": "Soporta {{sites}} y más.",
"moreDescription": "La lista completa de yt-dlp se actualiza constantemente por la comunidad.",
"moreTitle": "¿Necesitas otro sitio?",
"openFullList": "Abrir lista completa de sitios soportados",
"pageDescription": "VidBee usa yt-dlp bajo el capó para llegar a cientos de fuentes.",
"pageIntro": "Aquí están los servicios principales de los que la gente descarga con más frecuencia.",
"pageTitle": "Sitios Soportados",
"popular": {
"bandcamp": {
"description": "Álbumes de artistas independientes y lanzamientos de la comunidad.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Noticias globales, deportes y clips de entretenimiento.",
"label": "Dailymotion"
},
"facebook": {
"description": "Videos de Feed, Watch y Reels de páginas públicas.",
"label": "Facebook"
},
"instagram": {
"description": "Contenido de Feed, Stories, Reels y Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Transmisiones en vivo y repeticiones de creadores en la plataforma Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Charlas profesionales, seminarios web y videos de aprendizaje.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "Mezclas de DJ, programas de radio y audio de larga duración.",
"label": "Mixcloud"
},
"niconico": {
"description": "Archivo de animación japonesa, música y transmisión en vivo.",
"label": "Niconico"
},
"pinterest": {
"description": "Pines de ideas, reels de cómo hacer y videos de inspiración de estilo de vida.",
"label": "Pinterest"
},
"reddit": {
"description": "Clips incrustados y videos alojados de comunidades.",
"label": "Reddit"
},
"soundcloud": {
"description": "Pistas de música, listas de reproducción y sets de DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Videos móviles de formato corto, efectos y transmisiones en vivo.",
"label": "TikTok"
},
"tumblr": {
"description": "Medios de formato corto creativos y ediciones de fanáticos.",
"label": "Tumblr"
},
"twitch": {
"description": "Transmisiones en vivo de juegos, música e IRL y VODs.",
"label": "Twitch"
},
"twitter": {
"description": "Publicaciones de línea de tiempo, grabaciones de Spaces y transmisiones.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Alojamiento de video de alta calidad para creadores y empresas.",
"label": "Vimeo"
},
"youtube": {
"description": "Video de formato largo y transmisión en vivo de creadores de todo el mundo.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Videos musicales oficiales, álbumes y presentaciones en vivo.",
"label": "YouTube Music"
}
},
"popularSection": "Plataformas principales",
"viewAll": "Ver todos los sitios soportados"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Vérifier les mises à jour",
"download": "Télécharger",
"email": "Email",
"feedback": "Commentaires",
"goToDownload": "Aller à la page de téléchargement",
"openRepo": "Ouvrir le dépôt GitHub",
"view": "Voir",
"visit": "Visiter"
},
"appName": "VidBee",
"autoUpdateDescription": "Télécharger et installer automatiquement les nouvelles versions en arrière-plan.",
"autoUpdateTitle": "Mises à jour automatiques",
"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"
},
"followAuthorDescription": "Restez à jour avec les dernières nouvelles et mises à jour de VidBee.",
"followAuthorSupport": "Suivez le développeur sur X (Twitter) pour obtenir les dernières mises à jour et nouvelles sur VidBee.",
"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",
"downloadStarted": "Téléchargement démarré...",
"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",
"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}}"
},
"preferencesDescription": "Ajustez les paramètres de mise à jour sans quitter cette page.",
"preferencesTitle": "Basculements Rapides",
"resources": {
"changelog": "Notes de version",
"changelogDescription": "Suivez ce qui a changé dans chaque version.",
"contact": "Support par email",
"contactDescription": "Contactez directement pour de l'aide ou collaboration.",
"documentation": "Centre d'aide",
"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.",
"license": "Licence",
"licenseDescription": "Consultez les termes de la licence open-source.",
"website": "Site web officiel",
"websiteDescription": "Points forts du produit, feuille de route et actualités de la communauté."
},
"resourcesDescription": "Liens utiles pour en savoir plus sur VidBee et rester connecté.",
"resourcesTitle": "Ressources",
"shareActions": {
"copy": "Copier le lien",
"facebook": "Partager sur Facebook",
"twitter": "Partager sur X (Twitter)"
},
"shareDescription": "Partagez VidBee avec votre communauté en un clic.",
"shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.",
"shareTitle": "Faites passer le mot",
"sourceCode": "Le code source est disponible",
"title": "À propos",
"version": "Version",
"versionLabel": "v{{version}}"
},
"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",
"end": "Fin",
"endHint": "Si laissé vide, sera téléchargé jusqu'à la fin",
"endPlaceholder": "10:00",
"selectLocation": "Sélectionner l'Emplacement de Téléchargement",
"start": "Début",
"startHint": "Si laissé vide, commencera du début",
"startPlaceholder": "00:00",
"subtitles": "Sous-titres",
"timeRange": "Télécharger une plage de temps spécifique",
"title": "Options Avancées"
},
"app": {
"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",
"audio": "Audio",
"back": "Retour",
"cancel": "Annuler",
"cancelled": "Annulé",
"clearCompleted": "Effacer les Terminés",
"clearDownloads": "Effacer les Téléchargements",
"completed": "Terminé",
"downloadAudio": "Télécharger l'Audio",
"downloadBtn": "Télécharger",
"downloadPending": "En attente",
"downloadQueue": "File de Téléchargement",
"downloadVideo": "Télécharger la Vidéo",
"downloading": "Téléchargement en cours...",
"enterUrl": "Entrer l'URL de la Vidéo",
"enterUrlDescription": "Collez ou tapez une URL de vidéo. ",
"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é",
"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.",
"oneClickDownloadNow": "Télécharger Maintenant",
"oneClickDownloadStarted": "Téléchargement démarré avec les paramètres par défaut",
"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]",
"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",
"showDetails": "Afficher les détails",
"singleVideo": "Vidéo Unique",
"speed": "Vitesse",
"title": "Titre",
"total": "Total",
"unknownQuality": "Qualité inconnue",
"unknownSize": "Taille inconnue",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Vidéo",
"videoInfo": "Informations Vidéo",
"videoInfoUpdated": "Informations vidéo mises à jour"
},
"errors": {
"clickToCopy": "Cliquez pour copier les détails",
"clipboardEmpty": "Le presse-papiers est vide",
"downloadFailed": "Échec du téléchargement",
"downloadNecessaryFilesFailed": "Échec du téléchargement des fichiers nécessaires. Veuillez vérifier votre réseau et réessayer",
"emptyUrl": "Veuillez entrer une URL",
"errorDetails": "Détails de l'Erreur",
"fetchInfoFailed": "Échec de la récupération des informations vidéo",
"networkError": "Une erreur s'est produite. Vérifiez votre réseau et utilisez une URL correcte",
"pasteFromClipboard": "Échec du collage depuis le presse-papiers"
},
"history": {
"clearCancelled": "Effacer les Annulés",
"clearCompleted": "Effacer les Terminés",
"clearErrors": "Effacer les Erreurs",
"copyToClipboard": "Copier dans le presse-papiers",
"copyUrl": "Copier l'URL",
"date": "Date",
"description": "Voir et gérer votre historique de téléchargements",
"duration": "Durée",
"fileSize": "Taille du Fichier",
"filters": {
"all": "Tout",
"cancelled": "Annulé",
"completed": "Terminé",
"errors": "Erreurs"
},
"noHistory": "Aucun historique de téléchargement encore",
"noHistoryDescription": "Vos téléchargements terminés apparaîtront ici",
"openDownloadFolder": "Ouvrir le Dossier de Téléchargement",
"openFile": "Ouvrir le Fichier",
"openFileLocation": "Ouvrir l'Emplacement du Fichier",
"openFolder": "Ouvrir le Dossier",
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
"removeItem": "Supprimer l'Élément",
"stats": {
"cancelled": "Annulé",
"completed": "Terminé",
"errors": "Erreurs",
"total": "Total"
},
"status": {
"cancelled": "Annulé",
"completed": "Terminé",
"error": "Erreur"
},
"title": "Historique de Téléchargement"
},
"menu": {
"about": "À propos",
"download": "Télécharger",
"playlist": "Télécharger la Playlist",
"preferences": "Préférences",
"rss": "RSS",
"subscriptions": "Abonnements",
"supportedSites": "Sites Supportés",
"theme": "Thème :"
},
"notifications": {
"copyFailed": "Échec de la copie dans le presse-papiers",
"downloadCompleted": "Téléchargement terminé",
"downloadFailed": "Échec du téléchargement",
"downloadStarted": "Téléchargement démarré",
"itemRemoved": "Élément supprimé",
"openFileFailed": "Échec de l'ouverture du fichier",
"openFolderFailed": "Échec de l'ouverture du dossier",
"removeFailed": "Échec de la suppression de l'élément",
"settingsSaved": "Paramètres sauvegardés",
"urlCopied": "URL copiée dans le presse-papiers",
"videoCopied": "Vidéo copiée dans le presse-papiers"
},
"playlist": {
"badgeLabel": "Liste de lecture",
"clearPreview": "Effacer l'aperçu",
"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",
"downloadFailed": "Échec du démarrage du téléchargement de playlist",
"downloadPlaylist": "Télécharger la Playlist",
"downloadStarted": "Démarré le téléchargement de {{count}} vidéos de la playlist",
"downloadType": "Type de Téléchargement",
"downloading": "Téléchargement de la playlist :",
"endIndex": "Fin",
"enterPlaylistUrl": "Entrer l'URL de la Playlist",
"fetchFailed": "Échec de la récupération des informations de playlist",
"filenameFormat": "Format de nom de fichier pour les playlists",
"folderFormat": "Format de nom de dossier pour les playlists",
"foundVideos": "Trouvé {{count}} vidéos dans la playlist",
"groupActive": "{{count}} actifs",
"groupErrors": "{{count}} a échoué",
"groupSummary": "{{completed}} / {{total}} terminé",
"linkLabel": "URL de la Playlist",
"noEntries": "Aucune vidéo n'a été trouvée dans cette playlist",
"noEntriesInRange": "Aucune vidéo dans la plage sélectionnée",
"noRangeSelected": "Pas de fin - playlist complète sélectionnée",
"playlistUrlDescription": "Télécharger toutes les vidéos d'une playlist en lot",
"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.",
"range": "Plage (Optionnel)",
"resetToDefault": "Réinitialiser par défaut",
"selectedRange": "Plage : {{start}}-{{end}}",
"showingCount": "Affichage de {{count}} vidéos",
"startIndex": "Début (1)",
"title": "Télécharger la Playlist",
"totalVideos": "Nombre total de vidéos : {{count}}",
"untitled": "Liste de lecture sans titre"
},
"settings": {
"aboutTab": "À propos",
"advanced": "Avancé",
"app": "Paramètres de l'App",
"audio": "Préférences Audio",
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"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",
"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",
"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",
"maxConcurrentDownloads": "Nombre maximum de téléchargements actifs",
"maxConcurrentDownloadsDescription": "Nombre maximum de téléchargements simultanés",
"none": "Aucun",
"oneClickDownload": "Téléchargement en Un Clic",
"oneClickDownloadDescription": "Activer le téléchargement en un clic avec les paramètres par défaut",
"oneClickDownloadType": "Type de téléchargement par défaut",
"oneClickDownloadTypeDescription": "Choisissez le type de téléchargement par défaut pour les téléchargements en un clic. La qualité utilise le préréglage ci-dessous.",
"oneClickQuality": "Qualité préférée",
"oneClickQualityDescription": "Sélectionnez le préréglage de qualité utilisé pour les téléchargements en un clic",
"oneClickQualityOptions": {
"auto": "Auto",
"bad": "Mauvais",
"best": "Meilleur",
"good": "Bon",
"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",
"selectConfigFile": "Sélectionner le fichier de configuration",
"selectPath": "Sélectionner",
"showMoreFormats": "Afficher plus d'options de format",
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
"subscriptionDefaults": {
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier.",
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
},
"system": "Système",
"theme": "Thème",
"themeDescription": "Choisissez un thème clair, sombre ou système pour VidBee",
"title": "Paramètres",
"tray": {
"quit": "Quitter",
"showHome": "Afficher l'Accueil"
},
"video": "Préférences Vidéo"
},
"sites": {
"homeInlineDescription": "Supporte {{sites}} et plus.",
"moreDescription": "La liste complète yt-dlp est mise à jour constamment par la communauté.",
"moreTitle": "Besoin d'un autre site ?",
"openFullList": "Ouvrir la liste complète des sites supportés",
"pageDescription": "VidBee utilise yt-dlp en arrière-plan pour atteindre des centaines de sources.",
"pageIntro": "Voici les services principaux que les gens téléchargent le plus souvent.",
"pageTitle": "Sites Supportés",
"popular": {
"bandcamp": {
"description": "Albums d'artistes indépendants et sorties communautaires.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Actualités mondiales, sports et clips de divertissement.",
"label": "Dailymotion"
},
"facebook": {
"description": "Vidéos de flux, Watch et Reels des pages publiques.",
"label": "Facebook"
},
"instagram": {
"description": "Contenu de flux, Stories, Reels et Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Streams en direct et replays de créateurs sur la plateforme Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Conférences professionnelles, webinaires et vidéos d'apprentissage.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "Mixes DJ, émissions radio et audio long format.",
"label": "Mixcloud"
},
"niconico": {
"description": "Animation japonaise, musique et archives de diffusion en direct.",
"label": "Niconico"
},
"pinterest": {
"description": "Épingles d'idées, Reels de tutoriels et vidéos d'inspiration lifestyle.",
"label": "Pinterest"
},
"reddit": {
"description": "Clips intégrés et vidéos hébergées des communautés.",
"label": "Reddit"
},
"soundcloud": {
"description": "Pistes musicales, playlists et sets DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Vidéos courtes mobiles, effets et streams en direct.",
"label": "TikTok"
},
"tumblr": {
"description": "Médias courts créatifs et montages de fans.",
"label": "Tumblr"
},
"twitch": {
"description": "Streams en direct de gaming, musique et IRL et VOD.",
"label": "Twitch"
},
"twitter": {
"description": "Publications de timeline, enregistrements Spaces et diffusions.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Hébergement vidéo de haute qualité pour créateurs et entreprises.",
"label": "Vimeo"
},
"youtube": {
"description": "Vidéo long format et livestream de créateurs du monde entier.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Vidéos musicales officielles, albums et performances live.",
"label": "YouTube Music"
}
},
"popularSection": "Plateformes principales",
"viewAll": "Voir tous les sites supportés"
},
"subscriptions": {
"actions": {
"add": "Ajouter",
"disable": "Désactiver",
"edit": "Modifier",
"enable": "Activer",
"refresh": "Rafraîchir",
"remove": "Retirer",
"save": "Enregistrer les modifications",
"selectDirectory": "Parcourir"
},
"add": {
"description": "Collez un lien de flux RSS. \nVidBee détectera automatiquement le flux.",
"title": "Ajouter un flux RSS"
},
"defaults": {
"checkInterval": "Intervalle de vérification (heures)",
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
"downloadDirectory": "Répertoire de téléchargement",
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Lorsqu'il est activé, VidBee ignore les anciens éléments du backlog et récupère uniquement le téléchargement le plus récent.",
"title": "Paramètres par défaut de l'automatisation"
},
"description": "Surveillez automatiquement les flux RSS et mettez les nouveaux téléchargements en file dattente sans travail manuel.",
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
"detecting": "Détection du flux...",
"edit": {
"description": "Ajustez les filtres, les balises et les remplacements pour ce flux.",
"title": "Modifier {{name}}"
},
"empty": "Aucun abonnement pour l'instant. \nAjoutez vos chaînes préférées pour lancer le téléchargement automatique.",
"fields": {
"customDirectory": "Répertoire personnalisé",
"disabled": "Désactivé",
"enabled": "Activé",
"keywords": "Filtre de mots clés (séparés par des virgules)",
"namingTemplate": "Modèle de nom de fichier personnalisé (fichier uniquement)",
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
"onlyLatestDescription": "Ignorez les éléments du backlog et récupérez uniquement le téléchargement le plus récent à partir de ce flux.",
"onlyLatestShort": "Seulement le dernier",
"tags": "Balises automatiques",
"url": "URL du flux"
},
"items": {
"actions": {
"open": "Ouvrir dans le navigateur",
"queue": "Ajouter à la file d'attente de téléchargement"
},
"count": "{{count}} articles",
"empty": "Aucun élément de flux récent trouvé.",
"fromChannel": "De {{channel}}",
"status": {
"cancelled": "Annulé",
"completed": "Complété",
"downloading": "Téléchargement",
"error": "Échoué",
"notQueued": "Pas en file d'attente",
"pending": "En attente",
"processing": "Traitement",
"queued": "En file d'attente"
},
"title": "Derniers téléchargements ({{count}})",
"tooltip": {
"downloadPending": "En attente des détails du téléchargement...",
"downloadStatus": "État du téléchargement : {{status}}",
"notQueued": "Pas encore dans la file d'attente de téléchargement"
}
},
"labels": {
"noThumbnail": "Aucune vignette",
"subscription": "Abonnement",
"unknown": "Abonnement inconnu"
},
"lastChecked": "Dernière vérification : {{time}}",
"latestVideo": "Dernière vidéo : {{title}}",
"never": "Jamais",
"notifications": {
"createError": "Échec de l'ajout de l'abonnement.",
"created": "Abonnement ajouté",
"directoryError": "Échec de l'ouverture du sélecteur de répertoire.",
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
"itemQueued": "Ajouté à la file d'attente de téléchargement",
"missingUrl": "Veuillez d'abord coller un lien de chaîne.",
"openLinkError": "Échec de l'ouverture du lien vidéo.",
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
"refreshStarted": "Actualisation démarrée",
"removed": "Abonnement supprimé",
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
"updated": "Abonnement mis à jour"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"rssHub": {
"description": "Combinez VidBee avec RSSHub pour activer les abonnements et les téléchargements automatisés à partir de diverses plateformes. \nUne fois configuré, VidBee s'exécute en arrière-plan et télécharge automatiquement les dernières vidéos et contenus.",
"hint": "Vous n'avez pas d'URL de flux RSS ? \nUtilisez RSSHub pour générer des flux RSS pour YouTube, Twitter et des milliers d'autres plateformes.",
"learnMore": "En savoir plus sur RSSHub",
"openDocs": "Ouvrir la documentation RSSHub",
"title": "Abonnements automatisés avec RSSHub"
},
"status": {
"checking": "Vérification",
"failed": "Échoué",
"idle": "Inactif",
"title": "Statut",
"tooltip": {
"updatedAt": "Mise à jour : {{time}}"
},
"up-to-date": "À jour"
},
"subtitle": "{{count}} abonnement{{count, plural, one {} other {s}}}",
"title": "Abonnements"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Periksa pembaruan",
"download": "Unduh",
"email": "Email",
"feedback": "Masukan",
"goToDownload": "Buka halaman unduhan",
"openRepo": "Buka repositori GitHub",
"view": "Lihat",
"visit": "Kunjungi"
},
"appName": "VidBee",
"autoUpdateDescription": "Unduh dan instal rilis baru secara otomatis di latar belakang.",
"autoUpdateTitle": "Pembaruan otomatis",
"betaProgramDescription": "Terima build awal dan fitur yang akan datang sebelum orang lain.",
"betaProgramTitle": "Saluran pratinjau",
"description": "VidBee adalah pengunduh gratis dan open-source yang dibangun dengan Electron dan didukung oleh yt-dlp.",
"followAuthorActions": {
"follow": "Ikuti @nexmoex"
},
"followAuthorDescription": "Tetap update dengan berita dan pembaruan VidBee terbaru.",
"followAuthorSupport": "Ikuti pengembang di X (Twitter) untuk mendapatkan pembaruan dan berita terbaru tentang VidBee.",
"followAuthorTitle": "Ikuti Pengembang",
"here": "di sini",
"homepage": "Beranda",
"notifications": {
"checkingUpdates": "Mencari pembaruan...",
"downloadError": "Gagal mengunduh pembaruan",
"downloadStarted": "Unduhan dimulai...",
"downloadUpdate": "Unduh dan instal pembaruan {{version}}?",
"manualDownloadAction": "Unduh sekarang",
"noUpdatesAvailable": "Anda menggunakan versi terbaru",
"restartToUpdate": "Mulai ulang sekarang untuk menginstal pembaruan?",
"restartNowAction": "Mulai ulang sekarang",
"updateAvailable": "Pembaruan tersedia: {{version}}",
"updateAvailableMessage": "Versi baru {{version}} tersedia. Silakan unduh dari situs web resmi.",
"updateDownloaded": "Pembaruan diunduh, mulai ulang untuk menginstal",
"updateDownloadedVersion": "Pembaruan {{version}} diunduh, mulai ulang untuk menginstal",
"updateError": "Gagal memeriksa pembaruan: {{error}}",
"unknownErrorFallback": "Kesalahan tidak diketahui"
},
"preferencesDescription": "Sesuaikan pengaturan pembaruan tanpa meninggalkan halaman ini.",
"preferencesTitle": "Toggle Cepat",
"resources": {
"changelog": "Catatan rilis",
"changelogDescription": "Ikuti apa yang berubah di setiap versi.",
"contact": "Dukungan email",
"contactDescription": "Hubungi langsung untuk bantuan atau kolaborasi.",
"documentation": "Pusat bantuan",
"documentationDescription": "Panduan, FAQ, dan alur kerja umum.",
"feedback": "Masukan & masalah",
"feedbackDescription": "Bagikan ide atau laporkan masalah di GitHub.",
"license": "Lisensi",
"licenseDescription": "Tinjau ketentuan lisensi open-source.",
"website": "Situs web resmi",
"websiteDescription": "Sorotan produk, roadmap, dan berita komunitas."
},
"resourcesDescription": "Tautan berguna untuk mempelajari lebih lanjut tentang VidBee dan tetap terhubung.",
"resourcesTitle": "Sumber Daya",
"shareActions": {
"copy": "Salin tautan",
"facebook": "Bagikan di Facebook",
"twitter": "Bagikan di X (Twitter)"
},
"shareDescription": "Bagikan VidBee dengan komunitas Anda dalam satu klik.",
"shareSupport": "Rekomendasikan VidBee kepada teman-teman Anda untuk mendukung pertumbuhan dan pembaruan kami.",
"shareTitle": "Sebarkan berita",
"sourceCode": "Kode Sumber tersedia",
"title": "Tentang",
"version": "Versi",
"versionLabel": "v{{version}}",
"latestVersionBadge": "Terbaru: v{{version}}",
"latestVersionStatus": {
"available": "Versi baru tersedia",
"uptodate": "Anda sudah menggunakan versi terbaru",
"error": "Tidak dapat mengambil versi terbaru"
},
"downloadingUpdate": "Mengunduh pembaruan"
},
"advancedOptions": {
"closeWhenDone": "Tutup aplikasi saat unduhan selesai",
"currentLocation": "Lokasi unduhan saat ini - ",
"downloadLocation": "Lokasi unduhan",
"downloadSubs": "Unduh subtitle jika tersedia",
"end": "Akhir",
"endHint": "Jika dibiarkan kosong, akan diunduh sampai akhir",
"endPlaceholder": "10:00",
"selectLocation": "Pilih Lokasi Unduhan",
"start": "Mulai",
"startHint": "Jika dibiarkan kosong, akan dimulai dari awal",
"startPlaceholder": "00:00",
"subtitles": "Subtitle",
"timeRange": "Unduh rentang waktu tertentu",
"title": "Opsi Lanjutan"
},
"app": {
"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",
"audio": "Audio",
"back": "Kembali",
"cancel": "Batal",
"cancelled": "Dibatalkan",
"clearCompleted": "Hapus Selesai",
"clearDownloads": "Hapus Unduhan",
"completed": "Selesai",
"downloadAudio": "Unduh Audio",
"downloadBtn": "Unduh",
"downloadPending": "Menunggu",
"downloadQueue": "Antrian Unduhan",
"downloadVideo": "Unduh Video",
"downloading": "Mengunduh...",
"enterUrl": "Masukkan URL Video",
"enterUrlDescription": "Tempel atau ketik URL video. ",
"error": "Kesalahan",
"fetch": "Ambil",
"fetchingVideoInfo": "Mengambil info video...",
"history": "Riwayat",
"imageLoadError": "Gagal memuat gambar",
"imagePlaceholder": "Tidak ada gambar tersedia",
"infoUnavailable": "Unduh Satu Klik (Info tidak tersedia)",
"loading": "Memuat",
"moreOptions": "Opsi lainnya",
"noActiveDownloads": "Tidak ada unduhan aktif",
"noAudio": "Tidak Ada Audio",
"noHistory": "Tidak ada riwayat unduhan",
"noItems": "Tidak ada item ditemukan",
"goToSettings": "Buka Pengaturan",
"oneClickDownload": "Unduh Satu Klik",
"oneClickDownloadDescription": "Unduh langsung dengan pengaturan default tanpa konfirmasi",
"oneClickDownloadEnabled": "Unduh Satu Klik diaktifkan. Unduhan akan dimulai langsung dengan pengaturan default.",
"oneClickDownloadNow": "Unduh Sekarang",
"oneClickDownloadStarted": "Unduhan dimulai dengan pengaturan default",
"paste": "Tempel",
"pastePlaylistUrl": "Klik untuk menempelkan tautan playlist dari clipboard [Ctrl + V]",
"pasteUrl": "Klik untuk menempelkan URL video atau ID [Ctrl + V]",
"preparing": "Mempersiapkan...",
"processing": "Memproses",
"progress": "Kemajuan",
"showDetails": "Tampilkan detail",
"hideDetails": "Sembunyikan detail",
"selectAudioFormat": "Pilih Format Audio",
"selectFormat": "Pilih Format",
"selectVideoFormat": "Pilih Format Video",
"singleVideo": "Video Tunggal",
"speed": "Kecepatan",
"title": "Judul",
"total": "Total",
"unknownQuality": "Kualitas tidak diketahui",
"unknownSize": "Ukuran tidak diketahui",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Informasi Video",
"videoInfoUpdated": "Informasi video diperbarui",
"metadata": {
"source": "Sumber",
"playlist": "Playlist",
"format": "Format",
"quality": "Kualitas",
"codec": "Codec",
"savedFile": "File tersimpan",
"url": "URL Sumber",
"description": "Deskripsi",
"views": "Tayangan",
"tags": "Tag",
"downloadPath": "Jalur unduhan",
"createdAt": "Dibuat pada",
"startedAt": "Dimulai pada",
"completedAt": "Selesai pada",
"speed": "Kecepatan",
"fileSize": "Ukuran file",
"width": "Lebar",
"height": "Tinggi",
"fps": "FPS",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"formatNote": "Catatan format",
"protocol": "Protokol",
"subscription": "Berlangganan"
}
},
"errors": {
"clickToCopy": "Klik untuk menyalin detail",
"clipboardEmpty": "Clipboard kosong",
"downloadFailed": "Unduhan gagal",
"downloadNecessaryFilesFailed": "Gagal mengunduh file yang diperlukan. Silakan periksa jaringan Anda dan coba lagi",
"emptyUrl": "Silakan masukkan URL",
"errorDetails": "Detail Kesalahan",
"fetchInfoFailed": "Gagal mengambil informasi video",
"networkError": "Terjadi kesalahan. Periksa jaringan Anda dan gunakan URL yang benar",
"pasteFromClipboard": "Gagal menempel dari clipboard"
},
"history": {
"clearCancelled": "Hapus Dibatalkan",
"clearCompleted": "Hapus Selesai",
"clearErrors": "Hapus Kesalahan",
"copyToClipboard": "Salin ke clipboard",
"copyUrl": "Salin URL",
"date": "Tanggal",
"description": "Lihat dan kelola riwayat unduhan Anda",
"duration": "Durasi",
"fileSize": "Ukuran File",
"filters": {
"all": "Semua",
"cancelled": "Dibatalkan",
"completed": "Selesai",
"errors": "Kesalahan"
},
"noHistory": "Belum ada riwayat unduhan",
"noHistoryDescription": "Unduhan yang selesai akan muncul di sini",
"openDownloadFolder": "Buka Folder Unduhan",
"openFile": "Buka File",
"openFileLocation": "Buka Lokasi File",
"openFolder": "Buka Folder",
"openInBrowser": "Klik untuk membuka di browser",
"removeItem": "Hapus Item",
"stats": {
"cancelled": "Dibatalkan",
"completed": "Selesai",
"errors": "Kesalahan",
"total": "Total"
},
"status": {
"cancelled": "Dibatalkan",
"completed": "Selesai",
"error": "Kesalahan"
},
"title": "Riwayat Unduhan"
},
"menu": {
"about": "Tentang",
"download": "Unduh",
"playlist": "Unduh Playlist",
"rss": "RSS",
"subscriptions": "Berlangganan",
"preferences": "Preferensi",
"supportedSites": "Situs yang Didukung",
"theme": "Tema:"
},
"notifications": {
"copyFailed": "Gagal menyalin ke clipboard",
"downloadCompleted": "Unduhan selesai",
"downloadFailed": "Unduhan gagal",
"downloadStarted": "Unduhan dimulai",
"itemRemoved": "Item dihapus",
"openFileFailed": "Gagal membuka file",
"openFolderFailed": "Gagal membuka folder",
"removeFailed": "Gagal menghapus item",
"settingsSaved": "Pengaturan disimpan",
"urlCopied": "URL disalin ke clipboard",
"videoCopied": "Video disalin ke clipboard"
},
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Hapus pratinjau",
"comingSoon": "Fitur unduh playlist segera hadir!",
"completed": "Playlist diunduh",
"description": "Unduh semua video dari playlist atau saluran YouTube",
"downloadFailed": "Gagal memulai unduhan playlist",
"downloadPlaylist": "Unduh Playlist",
"downloadStarted": "Mulai mengunduh {{count}} video dari playlist",
"downloadType": "Jenis Unduhan",
"downloading": "Mengunduh playlist:",
"endIndex": "Akhir",
"enterPlaylistUrl": "Masukkan URL Playlist",
"fetchFailed": "Gagal mengambil informasi playlist",
"filenameFormat": "Format nama file untuk playlist",
"folderFormat": "Format nama folder untuk playlist",
"foundVideos": "Ditemukan {{count}} video dalam playlist",
"groupActive": "{{count}} aktif",
"groupErrors": "{{count}} gagal",
"groupSummary": "{{completed}} / {{total}} selesai",
"linkLabel": "URL Playlist",
"noEntries": "Tidak ada video yang ditemukan dalam playlist ini",
"noEntriesInRange": "Tidak ada video dalam rentang yang dipilih",
"noRangeSelected": "Tidak ada akhir yang ditetapkan - playlist penuh dipilih",
"playlistUrlDescription": "Unduh semua video dari playlist secara massal",
"positionLabel": "Item {{index}} dari {{total}}",
"previewButton": "Pratinjau playlist",
"previewFailed": "Gagal mempratinjau playlist",
"previewSummary": "Pratinjau item playlist sebelum mengunduh.",
"previewRequired": "Pratinjau playlist sebelum mengunduh.",
"range": "Rentang (Opsional)",
"resetToDefault": "Reset ke default",
"selectedRange": "Rentang: {{start}}-{{end}}",
"showingCount": "Menampilkan {{count}} video",
"startIndex": "Mulai (1)",
"title": "Unduh Playlist",
"totalVideos": "Total video: {{count}}",
"untitled": "Playlist tanpa judul"
},
"settings": {
"aboutTab": "Tentang",
"advanced": "Lanjutan",
"app": "Pengaturan Aplikasi",
"audio": "Preferensi Audio",
"browserForCookies": "Pilih browser untuk menggunakan cookie",
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
"cookiesFile": "File cookie",
"cookiesFileDescription": "File cookie format Netscape untuk dimuat untuk autentikasi",
"clearCookiesFile": "Hapus",
"cookiesHelpTitle": "Menggunakan cookie",
"cookiesHelpBrowser": "Pilih browser Anda di atas untuk menggunakan kembali sesi masuk secara otomatis.",
"cookiesHelpFile": "Ekspor file cookie Netscape (lihat FAQ yt-dlp) dan pilih di sini saat diperlukan.",
"cookiesHelpFaq": "Buka FAQ cookie yt-dlp",
"openLinkError": "Gagal membuka tautan",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "Gunakan file konfigurasi",
"configFileDescription": "File konfigurasi khusus untuk yt-dlp",
"clearConfigFile": "Hapus",
"dark": "Gelap",
"description": "Konfigurasikan preferensi unduhan dan pengaturan aplikasi Anda",
"directorySelectError": "Gagal memilih direktori",
"downloadPath": "Lokasi unduhan",
"downloadPathDescription": "Pilih tempat menyimpan file yang diunduh",
"fileSelectError": "Gagal memilih file",
"general": "Umum",
"language": "Bahasa",
"light": "Terang",
"hideDockIcon": "Sembunyikan ikon Dock",
"hideDockIconDescription": "Hapus VidBee dari Dock macOS. Gunakan menu bar atau ikon tray untuk membuka kembali aplikasi.",
"launchAtLogin": "Luncurkan saat startup",
"launchAtLoginDescription": "Buka VidBee secara otomatis setelah Anda masuk ke komputer Anda.",
"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.",
"maxConcurrentDownloads": "Jumlah maksimum unduhan aktif",
"maxConcurrentDownloadsDescription": "Jumlah maksimum unduhan bersamaan",
"none": "Tidak ada",
"oneClickDownload": "Unduh Satu Klik",
"oneClickDownloadDescription": "Aktifkan unduh satu klik dengan pengaturan default",
"oneClickDownloadType": "Jenis unduhan default",
"oneClickDownloadTypeDescription": "Pilih jenis unduhan default untuk unduhan satu klik. Kualitas menggunakan preset di bawah ini.",
"oneClickQuality": "Kualitas yang disukai",
"oneClickQualityDescription": "Pilih preset kualitas yang digunakan untuk unduhan satu klik",
"oneClickQualityOptions": {
"auto": "Otomatis",
"bad": "Buruk",
"best": "Terbaik",
"good": "Baik",
"normal": "Normal",
"worst": "Terburuk"
},
"proxy": "Proxy",
"proxyDescription": "Server proxy untuk permintaan jaringan",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Pilih file konfigurasi",
"selectPath": "Pilih",
"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)."
},
"system": "Sistem",
"theme": "Tema",
"themeDescription": "Pilih tema terang, gelap, atau sistem untuk VidBee",
"title": "Pengaturan",
"tray": {
"quit": "Keluar",
"showHome": "Tampilkan Beranda"
},
"video": "Preferensi Video"
},
"subscriptions": {
"title": "Berlangganan",
"subtitle": "{{count}} berlangganan{{count, plural, one {} other {}}}",
"description": "Pantau feed RSS secara otomatis dan antre unduhan baru tanpa pekerjaan manual.",
"defaults": {
"title": "Default otomatisasi",
"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."
},
"add": {
"title": "Tambah RSS",
"description": "Tempel tautan feed RSS. VidBee akan mendeteksi feed secara otomatis."
},
"fields": {
"url": "URL Feed",
"keywords": "Filter kata kunci (dipisahkan koma)",
"tags": "Tag otomatis",
"customDirectory": "Direktori khusus",
"namingTemplate": "Template nama file khusus (hanya file)",
"onlyLatest": "Unduh hanya video terbaru",
"onlyLatestDescription": "Abaikan item backlog dan ambil hanya unggahan terbaru dari feed ini.",
"enabled": "Diaktifkan",
"disabled": "Dinonaktifkan",
"onlyLatestShort": "Hanya terbaru"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"actions": {
"add": "Tambah",
"refresh": "Segarkan",
"edit": "Edit",
"remove": "Hapus",
"save": "Simpan perubahan",
"selectDirectory": "Jelajahi",
"enable": "Aktifkan",
"disable": "Nonaktifkan"
},
"items": {
"title": "Unggahan terbaru ({{count}})",
"count": "{{count}} item",
"empty": "Tidak ada item feed terbaru ditemukan.",
"status": {
"queued": "Diantre",
"notQueued": "Tidak diantre",
"pending": "Menunggu",
"downloading": "Mengunduh",
"processing": "Memproses",
"completed": "Selesai",
"error": "Gagal",
"cancelled": "Dibatalkan"
},
"fromChannel": "Dari {{channel}}",
"tooltip": {
"downloadStatus": "Status unduhan: {{status}}",
"downloadPending": "Menunggu detail unduhan...",
"notQueued": "Belum dalam antrian unduhan"
},
"actions": {
"open": "Buka di browser",
"queue": "Tambahkan ke antrian unduhan"
}
},
"labels": {
"subscription": "Berlangganan",
"unknown": "Berlangganan tidak diketahui",
"noThumbnail": "Tidak ada thumbnail"
},
"notifications": {
"directoryError": "Gagal membuka pemilih direktori.",
"missingUrl": "Silakan tempel tautan saluran terlebih dahulu.",
"created": "Berlangganan ditambahkan",
"createError": "Gagal menambahkan berlangganan.",
"refreshStarted": "Penyegaran dimulai",
"removed": "Berlangganan dihapus",
"updated": "Berlangganan diperbarui",
"itemQueued": "Ditambahkan ke antrian unduhan",
"itemAlreadyQueued": "Video ini sudah diantre",
"queueError": "Gagal menambahkan ke antrian unduhan.",
"openLinkError": "Gagal membuka tautan video.",
"resolveError": "Gagal menyelesaikan URL feed RSS."
},
"detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
"detecting": "Mendeteksi feed...",
"latestVideo": "Video terbaru: {{title}}",
"lastChecked": "Terakhir diperiksa: {{time}}",
"never": "Tidak pernah",
"empty": "Belum ada berlangganan. Tambahkan saluran favorit Anda untuk mulai mengunduh otomatis.",
"edit": {
"title": "Edit {{name}}",
"description": "Sesuaikan filter, tag, dan penggantian untuk feed ini."
},
"status": {
"title": "Status",
"up-to-date": "Terbaru",
"checking": "Memeriksa",
"failed": "Gagal",
"idle": "Menganggur",
"tooltip": {
"updatedAt": "Diperbarui: {{time}}"
}
},
"rssHub": {
"title": "Berlangganan Otomatis dengan RSSHub",
"description": "Gabungkan VidBee dengan RSSHub untuk mengaktifkan berlangganan dan unduhan otomatis dari berbagai platform. Setelah disetel, VidBee berjalan di latar belakang dan secara otomatis mengunduh video dan konten terbaru.",
"learnMore": "Pelajari lebih lanjut tentang RSSHub",
"openDocs": "Buka Dokumentasi RSSHub",
"hint": "Tidak punya URL feed RSS? Gunakan RSSHub untuk menghasilkan feed RSS untuk YouTube, Twitter, dan ribuan platform lainnya."
}
},
"sites": {
"homeInlineDescription": "Mendukung {{sites}} dan lainnya.",
"moreDescription": "Daftar lengkap yt-dlp terus diperbarui oleh komunitas.",
"moreTitle": "Perlu situs lain?",
"openFullList": "Buka daftar lengkap situs yang didukung",
"pageDescription": "VidBee menggunakan yt-dlp di balik layar untuk mencapai ratusan sumber.",
"pageIntro": "Berikut adalah layanan utama yang paling sering diunduh orang.",
"pageTitle": "Situs yang Didukung",
"popular": {
"bandcamp": {
"description": "Album artis independen dan rilis komunitas.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Klip berita, olahraga, dan hiburan global.",
"label": "Dailymotion"
},
"facebook": {
"description": "Video Feed, Watch, dan Reels dari halaman publik.",
"label": "Facebook"
},
"instagram": {
"description": "Konten Feed, Stories, Reels, dan Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Streaming langsung dan replay kreator di platform Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Pembicaraan profesional, webinar, dan video pembelajaran.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "Mix DJ, acara radio, dan audio format panjang.",
"label": "Mixcloud"
},
"niconico": {
"description": "Arsip animasi, musik, dan siaran langsung Jepang.",
"label": "Niconico"
},
"pinterest": {
"description": "Pin ide, reel cara-cara, dan video inspirasi gaya hidup.",
"label": "Pinterest"
},
"reddit": {
"description": "Klip tersemat dan video yang dihosting dari komunitas.",
"label": "Reddit"
},
"soundcloud": {
"description": "Lagu musik, playlist, dan set DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Video format pendek seluler, efek, dan streaming langsung.",
"label": "TikTok"
},
"tumblr": {
"description": "Media format pendek kreatif dan edit penggemar.",
"label": "Tumblr"
},
"twitch": {
"description": "Streaming langsung gaming, musik, dan IRL serta VOD.",
"label": "Twitch"
},
"twitter": {
"description": "Posting timeline, rekaman Spaces, dan siaran.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Hosting video kreator dan bisnis berkualitas tinggi.",
"label": "Vimeo"
},
"youtube": {
"description": "Video format panjang dan streaming langsung dari kreator di seluruh dunia.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Video musik resmi, album, dan pertunjukan langsung.",
"label": "YouTube Music"
}
},
"popularSection": "Platform utama",
"viewAll": "Lihat semua situs yang didukung"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Controlla aggiornamenti",
"download": "Scaricamento",
"email": "Email",
"feedback": "Feedback",
"goToDownload": "Vai alla pagina di download",
"openRepo": "Apri repository GitHub",
"view": "Visualizza",
"visit": "Visita"
},
"appName": "VidBee",
"autoUpdateDescription": "Scarica e installa automaticamente le nuove versioni in background.",
"autoUpdateTitle": "Aggiornamenti automatici",
"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"
},
"followAuthorDescription": "Rimani aggiornato con le ultime notizie e aggiornamenti di VidBee.",
"followAuthorSupport": "Segui lo sviluppatore su X (Twitter) per ottenere gli ultimi aggiornamenti e notizie su VidBee.",
"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",
"downloadStarted": "Download iniziato...",
"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",
"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}}"
},
"preferencesDescription": "Regola le impostazioni di aggiornamento senza lasciare questa pagina.",
"preferencesTitle": "Toggle Rapidi",
"resources": {
"changelog": "Note di rilascio",
"changelogDescription": "Tieni traccia di cosa è cambiato in ogni versione.",
"contact": "Supporto email",
"contactDescription": "Contatta direttamente per aiuto o collaborazione.",
"documentation": "Centro assistenza",
"documentationDescription": "Guide, FAQ e flussi di lavoro comuni.",
"feedback": "Feedback e problemi",
"feedbackDescription": "Condividi idee o segnala problemi su GitHub.",
"license": "Licenza",
"licenseDescription": "Rivedi i termini della licenza open-source.",
"website": "Sito web ufficiale",
"websiteDescription": "Punti salienti del prodotto, roadmap e notizie della comunità."
},
"resourcesDescription": "Link utili per saperne di più su VidBee e rimanere connessi.",
"resourcesTitle": "Risorse",
"shareActions": {
"copy": "Copia link",
"facebook": "Condividi su Facebook",
"twitter": "Condividi su X (Twitter)"
},
"shareDescription": "Condividi VidBee con la tua comunità in un clic.",
"shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.",
"shareTitle": "Passa parola",
"sourceCode": "Il codice sorgente è disponibile",
"title": "Informazioni",
"version": "Versione",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "Chiudi app quando il download finisce",
"currentLocation": "Posizione download attuale - ",
"downloadLocation": "Posizione download",
"downloadSubs": "Scarica sottotitoli se disponibili",
"end": "Fine",
"endHint": "Se lasciato vuoto, verrà scaricato fino alla fine",
"endPlaceholder": "10:00",
"selectLocation": "Seleziona Posizione Download",
"start": "Inizio",
"startHint": "Se lasciato vuoto, inizierà dall'inizio",
"startPlaceholder": "00:00",
"subtitles": "Sottotitoli",
"timeRange": "Scarica intervallo di tempo specifico",
"title": "Opzioni Avanzate"
},
"app": {
"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",
"audio": "Audio",
"back": "Indietro",
"cancel": "Annulla",
"cancelled": "Annullato",
"clearCompleted": "Cancella Completati",
"clearDownloads": "Cancella Download",
"completed": "Completato",
"downloadAudio": "Scarica Audio",
"downloadBtn": "Scarica",
"downloadPending": "In attesa",
"downloadQueue": "Coda Download",
"downloadVideo": "Scarica Video",
"downloading": "Scaricando...",
"enterUrl": "Inserisci URL Video",
"enterUrlDescription": "Incolla o digita un URL video. ",
"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",
"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.",
"oneClickDownloadNow": "Scarica Ora",
"oneClickDownloadStarted": "Download iniziato con impostazioni predefinite",
"paste": "Incolla",
"pastePlaylistUrl": "Clicca per incollare link playlist dagli appunti [Ctrl + V]",
"pasteUrl": "Clicca per incollare URL video o ID [Ctrl + V]",
"preparing": "Preparazione...",
"processing": "Elaborazione",
"progress": "Progresso",
"selectAudioFormat": "Seleziona Formato Audio",
"selectFormat": "Seleziona Formato",
"selectVideoFormat": "Seleziona Formato Video",
"showDetails": "Mostra dettagli",
"singleVideo": "Video Singolo",
"speed": "Velocità",
"title": "Titolo",
"total": "Totale",
"unknownQuality": "Qualità sconosciuta",
"unknownSize": "Dimensione sconosciuta",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Informazioni Video",
"videoInfoUpdated": "Informazioni video aggiornate"
},
"errors": {
"clickToCopy": "Clicca per copiare i dettagli",
"clipboardEmpty": "Gli appunti sono vuoti",
"downloadFailed": "Download fallito",
"downloadNecessaryFilesFailed": "Errore nel download dei file necessari. Controlla la tua rete e riprova",
"emptyUrl": "Inserisci un URL",
"errorDetails": "Dettagli Errore",
"fetchInfoFailed": "Errore nel recupero delle informazioni video",
"networkError": "Si è verificato un errore. Controlla la tua rete e usa un URL corretto",
"pasteFromClipboard": "Errore nell'incollare dagli appunti"
},
"history": {
"clearCancelled": "Cancella Annullati",
"clearCompleted": "Cancella Completati",
"clearErrors": "Cancella Errori",
"copyToClipboard": "Copia negli appunti",
"copyUrl": "Copia URL",
"date": "Data",
"description": "Visualizza e gestisci la tua cronologia download",
"duration": "Durata",
"fileSize": "Dimensione File",
"filters": {
"all": "Tutto",
"cancelled": "Annullato",
"completed": "Completato",
"errors": "Errori"
},
"noHistory": "Nessuna cronologia download ancora",
"noHistoryDescription": "I tuoi download completati appariranno qui",
"openDownloadFolder": "Apri Cartella Download",
"openFile": "Apri File",
"openFileLocation": "Apri Posizione File",
"openFolder": "Apri Cartella",
"openInBrowser": "Clicca per aprire nel browser",
"removeItem": "Rimuovi Elemento",
"stats": {
"cancelled": "Annullato",
"completed": "Completato",
"errors": "Errori",
"total": "Totale"
},
"status": {
"cancelled": "Annullato",
"completed": "Completato",
"error": "Errore"
},
"title": "Cronologia Download"
},
"menu": {
"about": "Informazioni",
"download": "Scarica",
"playlist": "Scarica Playlist",
"preferences": "Preferenze",
"rss": "RSS",
"subscriptions": "Abbonamenti",
"supportedSites": "Siti Supportati",
"theme": "Tema:"
},
"notifications": {
"copyFailed": "Errore nella copia negli appunti",
"downloadCompleted": "Download completato",
"downloadFailed": "Download fallito",
"downloadStarted": "Download iniziato",
"itemRemoved": "Elemento rimosso",
"openFileFailed": "Errore nell'apertura del file",
"openFolderFailed": "Errore nell'apertura della cartella",
"removeFailed": "Errore nella rimozione dell'elemento",
"settingsSaved": "Impostazioni salvate",
"urlCopied": "URL copiato negli appunti",
"videoCopied": "Video copiato negli appunti"
},
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Anteprima chiara",
"comingSoon": "La funzionalità di download playlist arriverà presto!",
"completed": "Playlist scaricata",
"description": "Scarica tutti i video da una playlist o canale YouTube",
"downloadFailed": "Errore nell'avvio del download playlist",
"downloadPlaylist": "Scarica Playlist",
"downloadStarted": "Iniziato download di {{count}} video dalla playlist",
"downloadType": "Tipo Download",
"downloading": "Scaricando playlist:",
"endIndex": "Fine",
"enterPlaylistUrl": "Inserisci URL Playlist",
"fetchFailed": "Errore nel recupero delle informazioni playlist",
"filenameFormat": "Formato nome file per playlist",
"folderFormat": "Formato nome cartella per playlist",
"foundVideos": "Trovati {{count}} video nella playlist",
"groupActive": "{{count}} attivi",
"groupErrors": "{{count}} non riuscito",
"groupSummary": "{{completato}} / {{totale}} completati",
"linkLabel": "URL Playlist",
"noEntries": "Nessun video trovato in questa playlist",
"noEntriesInRange": "Nessun video nell'intervallo selezionato",
"noRangeSelected": "Nessun set finale: playlist completa selezionata",
"playlistUrlDescription": "Scarica tutti i video da una playlist in blocco",
"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.",
"range": "Intervallo (Opzionale)",
"resetToDefault": "Ripristina predefinito",
"selectedRange": "Intervallo: {{inizio}}-{{fine}}",
"showingCount": "Visualizzazione di {{count}} video",
"startIndex": "Inizio (1)",
"title": "Scarica Playlist",
"totalVideos": "Video totali: {{count}}",
"untitled": "Playlist senza titolo"
},
"settings": {
"aboutTab": "Informazioni",
"advanced": "Avanzato",
"app": "Impostazioni App",
"audio": "Preferenze Audio",
"browserForCookies": "Seleziona browser per usare i cookie",
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"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",
"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",
"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",
"maxConcurrentDownloads": "Numero massimo di download attivi",
"maxConcurrentDownloadsDescription": "Numero massimo di download simultanei",
"none": "Nessuno",
"oneClickDownload": "Download con Un Clic",
"oneClickDownloadDescription": "Abilita download con un clic con impostazioni predefinite",
"oneClickDownloadType": "Tipo download predefinito",
"oneClickDownloadTypeDescription": "Scegli il tipo di download predefinito per i download con un clic. La qualità usa il preset qui sotto.",
"oneClickQuality": "Qualità preferita",
"oneClickQualityDescription": "Seleziona il preset di qualità utilizzato per i download con un clic",
"oneClickQualityOptions": {
"auto": "Auto",
"bad": "Cattivo",
"best": "Migliore",
"good": "Buono",
"normal": "Normale",
"worst": "Peggiore"
},
"openLinkError": "Impossibile aprire il collegamento",
"proxy": "Proxy",
"proxyDescription": "Server proxy per le richieste di rete",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Seleziona file di configurazione",
"selectPath": "Seleziona",
"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)."
},
"system": "Sistema",
"theme": "Tema",
"themeDescription": "Scegli un tema chiaro, scuro o sistema per VidBee",
"title": "Impostazioni",
"tray": {
"quit": "Esci",
"showHome": "Mostra Home"
},
"video": "Preferenze Video"
},
"sites": {
"homeInlineDescription": "Supporta {{sites}} e altro.",
"moreDescription": "La lista completa yt-dlp viene aggiornata costantemente dalla comunità.",
"moreTitle": "Hai bisogno di un altro sito?",
"openFullList": "Apri lista completa siti supportati",
"pageDescription": "VidBee usa yt-dlp sotto il cofano per raggiungere centinaia di fonti.",
"pageIntro": "Ecco i servizi principali da cui le persone scaricano più spesso.",
"pageTitle": "Siti Supportati",
"popular": {
"bandcamp": {
"description": "Album di artisti indipendenti e uscite della comunità.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Notizie globali, sport e clip di intrattenimento.",
"label": "Dailymotion"
},
"facebook": {
"description": "Video di feed, Watch e Reels da pagine pubbliche.",
"label": "Facebook"
},
"instagram": {
"description": "Contenuto di feed, Stories, Reels e Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Stream live e replay di creatori sulla piattaforma Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Talk professionali, webinar e video di apprendimento.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "Mix DJ, programmi radio e audio long-form.",
"label": "Mixcloud"
},
"niconico": {
"description": "Animazione giapponese, musica e archivio di trasmissioni live.",
"label": "Niconico"
},
"pinterest": {
"description": "Pin di idee, Reels tutorial e video di ispirazione lifestyle.",
"label": "Pinterest"
},
"reddit": {
"description": "Clip incorporati e video ospitati dalle comunità.",
"label": "Reddit"
},
"soundcloud": {
"description": "Traccia musicali, playlist e set DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Video brevi mobili, effetti e stream live.",
"label": "TikTok"
},
"tumblr": {
"description": "Media brevi creativi e montaggi di fan.",
"label": "Tumblr"
},
"twitch": {
"description": "Stream live di gaming, musica e IRL e VOD.",
"label": "Twitch"
},
"twitter": {
"description": "Post di timeline, registrazioni Spaces e trasmissioni.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Hosting video di alta qualità per creatori e aziende.",
"label": "Vimeo"
},
"youtube": {
"description": "Video long-form e livestream da creatori di tutto il mondo.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Video musicali ufficiali, album e performance live.",
"label": "YouTube Music"
}
},
"popularSection": "Piattaforme principali",
"viewAll": "Visualizza tutti i siti supportati"
},
"subscriptions": {
"actions": {
"add": "Aggiungere",
"disable": "Disabilita",
"edit": "Modificare",
"enable": "Abilitare",
"refresh": "Aggiorna",
"remove": "Rimuovere",
"save": "Salva modifiche",
"selectDirectory": "Sfoglia"
},
"add": {
"description": "Incolla un collegamento al feed RSS. \nVidBee rileverà automaticamente il feed.",
"title": "Aggiungi RSS"
},
"defaults": {
"checkInterval": "Intervallo di controllo (ore)",
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
"downloadDirectory": "Scarica la directory",
"filenameTemplate": "Modello nome file (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Se abilitato, VidBee salta gli elementi del backlog più vecchi e acquisisce solo il caricamento più recente.",
"title": "Impostazioni predefinite dell'automazione"
},
"description": "Monitora automaticamente i feed RSS e accoda i nuovi download senza lavoro manuale.",
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
"detecting": "Rilevamento alimentazione...",
"edit": {
"description": "Modifica filtri, tag e sostituzioni per questo feed.",
"title": "Modifica {{nome}}"
},
"empty": "Nessun abbonamento ancora. \nAggiungi i tuoi canali preferiti per avviare il download automatico.",
"fields": {
"customDirectory": "Directory personalizzata",
"disabled": "Disabilitato",
"enabled": "Abilitato",
"keywords": "Filtro parole chiave (separati da virgole)",
"namingTemplate": "Modello nome file personalizzato (solo file)",
"onlyLatest": "Scarica solo il video più recente",
"onlyLatestDescription": "Ignora gli elementi del backlog e recupera solo il caricamento più recente da questo feed.",
"onlyLatestShort": "Solo più recente",
"tags": "Tag automatici",
"url": "URL del feed"
},
"items": {
"actions": {
"open": "Apri nel browser",
"queue": "Aggiungi alla coda di download"
},
"count": "{{count}} articoli",
"empty": "Nessun elemento del feed recente trovato.",
"fromChannel": "Da {{canale}}",
"status": {
"cancelled": "Annullato",
"completed": "Completato",
"downloading": "Download in corso",
"error": "Fallito",
"notQueued": "Non in coda",
"pending": "In attesa di",
"processing": "Elaborazione",
"queued": "In coda"
},
"title": "Ultimi caricamenti ({{count}})",
"tooltip": {
"downloadPending": "In attesa dei dettagli per il download...",
"downloadStatus": "Stato del download: {{status}}",
"notQueued": "Non ancora nella coda di download"
}
},
"labels": {
"noThumbnail": "Nessuna miniatura",
"subscription": "Sottoscrizione",
"unknown": "Abbonamento sconosciuto"
},
"lastChecked": "Ultimo controllo: {{time}}",
"latestVideo": "Ultimo video: {{title}}",
"never": "Mai",
"notifications": {
"createError": "Impossibile aggiungere l'abbonamento.",
"created": "Abbonamento aggiunto",
"directoryError": "Impossibile aprire il selettore di directory.",
"itemAlreadyQueued": "Questo video è già in coda",
"itemQueued": "Aggiunto alla coda di download",
"missingUrl": "Incolla prima il collegamento al canale.",
"openLinkError": "Impossibile aprire il collegamento video.",
"queueError": "Impossibile aggiungere alla coda di download.",
"refreshStarted": "Aggiornamento avviato",
"removed": "Abbonamento rimosso",
"resolveError": "Impossibile risolvere l'URL del feed RSS.",
"updated": "Abbonamento aggiornato"
},
"placeholders": {
"url": "https://rsshub.app/youtube/user/@FKJ"
},
"rssHub": {
"description": "Combina VidBee con RSSHub per abilitare abbonamenti e download automatizzati da varie piattaforme. \nUna volta configurato, VidBee viene eseguito in background e scarica automaticamente i video e i contenuti più recenti.",
"hint": "Non hai l'URL del feed RSS? \nUtilizza RSSHub per generare feed RSS per YouTube, Twitter e migliaia di altre piattaforme.",
"learnMore": "Ulteriori informazioni su RSSHub",
"openDocs": "Apri la documentazione RSSHub",
"title": "Abbonamenti automatizzati con RSSHub"
},
"status": {
"checking": "Controllo",
"failed": "Fallito",
"idle": "Oziare",
"title": "Stato",
"tooltip": {
"updatedAt": "Aggiornato: {{time}}"
},
"up-to-date": "Aggiornato"
},
"subtitle": "{{count}} abbonamento{{count, plural, one {} other {s}}}",
"title": "Abbonamenti"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "アップデートを確認",
"download": "ダウンロード",
"email": "メール",
"feedback": "フィードバック",
"goToDownload": "ダウンロードページへ行く",
"openRepo": "GitHubリポジトリを開く",
"view": "表示",
"visit": "訪問"
},
"appName": "VidBee",
"autoUpdateDescription": "バックグラウンドで新しいリリースを自動的にダウンロードしてインストールします。",
"autoUpdateTitle": "自動アップデート",
"betaProgramDescription": "他の人より先に早期ビルドと今後の機能を受け取ります。",
"betaProgramTitle": "プレビューチャンネル",
"description": "VidBeeはElectronで構築され、yt-dlpによって動力を得る無料のオープンソースダウンローダーです。",
"downloadingUpdate": "アップデートをダウンロードしています",
"followAuthorActions": {
"follow": "@nexmoexをフォロー"
},
"followAuthorDescription": "VidBeeの最新ニュースとアップデートを入手してください。",
"followAuthorSupport": "X (Twitter)で開発者をフォローして、VidBeeの最新アップデートとニュースを入手してください。",
"followAuthorTitle": "開発者をフォロー",
"here": "ここ",
"homepage": "ホームページ",
"latestVersionBadge": "最新: v{{version}}",
"latestVersionStatus": {
"available": "新しいバージョンが利用可能",
"error": "最新バージョンを取得できません",
"uptodate": "最新バージョンを使用中"
},
"notifications": {
"checkingUpdates": "アップデートを検索中...",
"downloadError": "アップデートのダウンロードに失敗",
"downloadStarted": "ダウンロード開始...",
"downloadUpdate": "アップデート{{version}}をダウンロードしてインストールしますか?",
"manualDownloadAction": "今すぐダウンロード",
"noUpdatesAvailable": "最新バージョンを使用しています",
"restartNowAction": "今すぐ再起動してください",
"restartToUpdate": "今すぐ再起動してアップデートをインストールしますか?",
"unknownErrorFallback": "不明なエラー",
"updateAvailable": "利用可能なアップデート:{{version}}",
"updateAvailableMessage": "新しいバージョン {{version}} が利用可能です。\n公式サイトからダウンロードしてください。",
"updateDownloaded": "アップデートがダウンロードされました。再起動してインストールしてください",
"updateDownloadedVersion": "アップデート {{version}} をダウンロードしました。再起動してインストールしてください",
"updateError": "アップデートの確認に失敗:{{error}}"
},
"preferencesDescription": "このページを離れることなくアップデート設定を調整します。",
"preferencesTitle": "クイックトグル",
"resources": {
"changelog": "リリースノート",
"changelogDescription": "各バージョンで何が変更されたかを追跡します。",
"contact": "メールサポート",
"contactDescription": "ヘルプやコラボレーションのために直接連絡してください。",
"documentation": "ヘルプセンター",
"documentationDescription": "ガイド、FAQ、一般的なワークフロー。",
"feedback": "フィードバックと問題",
"feedbackDescription": "GitHubでアイデアを共有したり問題を報告したりしてください。",
"license": "ライセンス",
"licenseDescription": "オープンソースライセンス条項を確認してください。",
"website": "公式ウェブサイト",
"websiteDescription": "製品のハイライト、ロードマップ、コミュニティニュース。"
},
"resourcesDescription": "VidBeeについてもっと学び、つながりを保つための有用なリンク。",
"resourcesTitle": "リソース",
"shareActions": {
"copy": "リンクをコピー",
"facebook": "Facebookで共有",
"twitter": "X (Twitter)で共有"
},
"shareDescription": "ワンクリックでコミュニティとVidBeeを共有してください。",
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
"shareTitle": "口コミを広める",
"sourceCode": "ソースコードが利用可能",
"title": "について",
"version": "バージョン",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "ダウンロード完了時にアプリを閉じる",
"currentLocation": "現在のダウンロード場所 - ",
"downloadLocation": "ダウンロード場所",
"downloadSubs": "利用可能な場合は字幕をダウンロード",
"end": "終了",
"endHint": "空のままにすると最後までダウンロードされます",
"endPlaceholder": "10:00",
"selectLocation": "ダウンロード場所を選択",
"start": "開始",
"startHint": "空のままにすると最初から開始されます",
"startPlaceholder": "00:00",
"subtitles": "字幕",
"timeRange": "特定の時間範囲をダウンロード",
"title": "高度なオプション"
},
"app": {
"description": "数百のサイトからビデオとオーディオをダウンロード",
"title": "VidBee"
},
"audioExtract": {
"bad": "悪い",
"best": "最高",
"extract": "抽出",
"good": "良い",
"normal": "通常",
"selectFormat": "フォーマットを選択",
"selectQuality": "品質を選択",
"title": "オーディオを抽出",
"worst": "最悪"
},
"download": {
"active": "アクティブ",
"all": "すべて",
"audio": "オーディオ",
"back": "戻る",
"cancel": "キャンセル",
"cancelled": "キャンセル済み",
"clearCompleted": "完了をクリア",
"clearDownloads": "ダウンロードをクリア",
"completed": "完了",
"downloadAudio": "オーディオをダウンロード",
"downloadBtn": "ダウンロード",
"downloadPending": "保留中",
"downloadQueue": "ダウンロードキュー",
"downloadVideo": "ビデオをダウンロード",
"downloading": "ダウンロード中...",
"enterUrl": "ビデオURLを入力",
"enterUrlDescription": "ビデオURLを貼り付けまたは入力してください。 ",
"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": "アイテムが見つかりません",
"oneClickDownload": "ワンクリックダウンロード",
"oneClickDownloadDescription": "確認なしでデフォルト設定で直接ダウンロード",
"oneClickDownloadEnabled": "ワンクリックダウンロードが有効になります。\nダウンロードはデフォルト設定で直接開始されます。",
"oneClickDownloadNow": "今すぐダウンロード",
"oneClickDownloadStarted": "デフォルト設定でダウンロード開始",
"paste": "貼り付け",
"pastePlaylistUrl": "クリップボードからプレイリストリンクを貼り付け [Ctrl + V]",
"pasteUrl": "ビデオURLまたはIDを貼り付け [Ctrl + V]",
"preparing": "準備中...",
"processing": "処理中",
"progress": "進行状況",
"selectAudioFormat": "オーディオフォーマットを選択",
"selectFormat": "フォーマットを選択",
"selectVideoFormat": "ビデオフォーマットを選択",
"showDetails": "詳細を表示",
"singleVideo": "単一ビデオ",
"speed": "速度",
"title": "タイトル",
"total": "合計",
"unknownQuality": "不明な品質",
"unknownSize": "不明なサイズ",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "ビデオ",
"videoInfo": "ビデオ情報",
"videoInfoUpdated": "ビデオ情報が更新されました"
},
"errors": {
"clickToCopy": "詳細をコピーするにはクリック",
"clipboardEmpty": "クリップボードが空です",
"downloadFailed": "ダウンロードに失敗",
"downloadNecessaryFilesFailed": "必要なファイルのダウンロードに失敗しました。ネットワークを確認して再試行してください",
"emptyUrl": "URLを入力してください",
"errorDetails": "エラーの詳細",
"fetchInfoFailed": "ビデオ情報の取得に失敗",
"networkError": "エラーが発生しました。ネットワークを確認し、正しいURLを使用してください",
"pasteFromClipboard": "クリップボードからの貼り付けに失敗"
},
"history": {
"clearCancelled": "キャンセル済みをクリア",
"clearCompleted": "完了をクリア",
"clearErrors": "エラーをクリア",
"copyToClipboard": "クリップボードにコピー",
"copyUrl": "URLをコピー",
"date": "日付",
"description": "ダウンロード履歴を表示および管理",
"duration": "期間",
"fileSize": "ファイルサイズ",
"filters": {
"all": "すべて",
"cancelled": "キャンセル済み",
"completed": "完了",
"errors": "エラー"
},
"noHistory": "ダウンロード履歴はまだありません",
"noHistoryDescription": "完了したダウンロードがここに表示されます",
"openDownloadFolder": "ダウンロードフォルダを開く",
"openFile": "ファイルを開く",
"openFileLocation": "ファイルの場所を開く",
"openFolder": "フォルダを開く",
"openInBrowser": "ブラウザで開くにはクリック",
"removeItem": "アイテムを削除",
"stats": {
"cancelled": "キャンセル済み",
"completed": "完了",
"errors": "エラー",
"total": "合計"
},
"status": {
"cancelled": "キャンセル済み",
"completed": "完了",
"error": "エラー"
},
"title": "ダウンロード履歴"
},
"menu": {
"about": "について",
"download": "ダウンロード",
"playlist": "プレイリストをダウンロード",
"preferences": "設定",
"rss": "RSS",
"subscriptions": "定期購入",
"supportedSites": "サポートされているサイト",
"theme": "テーマ:"
},
"notifications": {
"copyFailed": "クリップボードへのコピーに失敗",
"downloadCompleted": "ダウンロード完了",
"downloadFailed": "ダウンロードに失敗",
"downloadStarted": "ダウンロード開始",
"itemRemoved": "アイテムが削除されました",
"openFileFailed": "ファイルの開封に失敗",
"openFolderFailed": "フォルダの開封に失敗",
"removeFailed": "アイテムの削除に失敗",
"settingsSaved": "設定が保存されました",
"urlCopied": "URLがクリップボードにコピーされました",
"videoCopied": "ビデオがクリップボードにコピーされました"
},
"playlist": {
"badgeLabel": "プレイリスト",
"clearPreview": "プレビューをクリアする",
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
"completed": "プレイリストがダウンロードされました",
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
"downloadFailed": "プレイリストダウンロードの開始に失敗",
"downloadPlaylist": "プレイリストをダウンロード",
"downloadStarted": "プレイリストから{{count}}個のビデオのダウンロードを開始",
"downloadType": "ダウンロードタイプ",
"downloading": "プレイリストをダウンロード中:",
"endIndex": "終了",
"enterPlaylistUrl": "プレイリストURLを入力",
"fetchFailed": "プレイリスト情報の取得に失敗",
"filenameFormat": "プレイリスト用ファイル名フォーマット",
"folderFormat": "プレイリスト用フォルダ名フォーマット",
"foundVideos": "プレイリストで{{count}}個のビデオを発見",
"groupActive": "{{count}} 個がアクティブです",
"groupErrors": "{{count}} 回失敗しました",
"groupSummary": "{{完了}} / {{合計}} 完了",
"linkLabel": "プレイリストURL",
"noEntries": "このプレイリストにはビデオが見つかりませんでした",
"noEntriesInRange": "選択した範囲にビデオがありません",
"noRangeSelected": "終了セットなし - 完全なプレイリストが選択されています",
"playlistUrlDescription": "プレイリストからすべてのビデオを一括ダウンロード",
"positionLabel": "{{total}} 中のアイテム {{index}}",
"previewButton": "プレイリストをプレビューする",
"previewFailed": "プレイリストのプレビューに失敗しました",
"previewRequired": "ダウンロードする前にプレイリストをプレビューします。",
"previewSummary": "ダウンロードする前にプレイリスト項目をプレビューします。",
"range": "範囲(オプション)",
"resetToDefault": "デフォルトにリセット",
"selectedRange": "範囲: {{開始}}-{{終了}}",
"showingCount": "{{count}} 本の動画を表示しています",
"startIndex": "開始1",
"title": "プレイリストをダウンロード",
"totalVideos": "合計動画: {{count}}",
"untitled": "無題のプレイリスト"
},
"settings": {
"aboutTab": "について",
"advanced": "高度",
"app": "アプリ設定",
"audio": "オーディオ設定",
"browserForCookies": "Cookieに使用するブラウザを選択",
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"clearConfigFile": "クリア",
"clearCookiesFile": "クリア",
"configFile": "設定ファイルを使用",
"configFileDescription": "yt-dlp用のカスタム設定ファイル",
"cookiesFile": "クッキーファイル",
"cookiesFileDescription": "認証のためにロードする Netscape 形式の Cookie ファイル",
"cookiesHelpBrowser": "サインイン セッションを自動的に再利用するには、上記のブラウザーを選択してください。",
"cookiesHelpFaq": "yt-dlp Cookie を開くに関するよくある質問",
"cookiesHelpFile": "Netscape Cookie ファイルをエクスポートし (yt-dlp FAQ を参照)、必要に応じてここで選択します。",
"cookiesHelpTitle": "クッキーの使用",
"dark": "ダーク",
"description": "ダウンロード設定とアプリ設定を構成",
"directorySelectError": "ディレクトリの選択に失敗",
"downloadPath": "ダウンロード場所",
"downloadPathDescription": "ダウンロードファイルの保存場所を選択",
"enableAnalytics": "VidBee の改善にご協力ください",
"enableAnalyticsDescription": "匿名の使用状況データを共有することで、アプリの使用状況を把握し、改善の優先順位を付けることができます。",
"fileSelectError": "ファイルの選択に失敗",
"general": "一般",
"hideDockIcon": "ドックアイコンを非表示にする",
"hideDockIconDescription": "VidBee を macOS Dock から削除します。\nメニュー バーまたはトレイ アイコンを使用して、アプリを再度開きます。",
"language": "言語",
"launchAtLogin": "起動時に起動する",
"launchAtLoginDescription": "コンピューターにサインインした後、VidBee を自動的に開きます。",
"launchAtLoginUnsupported": "自動起動は macOS と Windows でのみ利用できます。",
"light": "ライト",
"maxConcurrentDownloads": "最大アクティブダウンロード数",
"maxConcurrentDownloadsDescription": "最大同時ダウンロード数",
"none": "なし",
"oneClickDownload": "ワンクリックダウンロード",
"oneClickDownloadDescription": "デフォルト設定でワンクリックダウンロードを有効化",
"oneClickDownloadType": "デフォルトダウンロードタイプ",
"oneClickDownloadTypeDescription": "ワンクリックダウンロードのデフォルトダウンロードタイプを選択。品質は下のプリセットを使用。",
"oneClickQuality": "優先品質",
"oneClickQualityDescription": "ワンクリックダウンロードに使用される品質プリセットを選択",
"oneClickQualityOptions": {
"auto": "自動",
"bad": "悪い",
"best": "最高",
"good": "良い",
"normal": "通常",
"worst": "最悪"
},
"openLinkError": "リンクを開けませんでした",
"proxy": "プロキシ",
"proxyDescription": "ネットワークリクエスト用のプロキシサーバー",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "設定ファイルを選択",
"selectPath": "選択",
"showMoreFormats": "より多くのフォーマットオプションを表示",
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
"subscriptionDefaults": {
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 24 時間)。"
},
"system": "システム",
"theme": "テーマ",
"themeDescription": "VidBeeのライト、ダーク、またはシステムテーマを選択",
"title": "設定",
"tray": {
"quit": "終了",
"showHome": "ホームを表示"
},
"video": "ビデオ設定"
},
"sites": {
"homeInlineDescription": "{{sites}}およびその他のサイトをサポートしています。",
"moreDescription": "完全なyt-dlpリストはコミュニティによって継続的に更新されています。",
"moreTitle": "他のサイトが必要ですか?",
"openFullList": "サポートされているすべてのサイトリストを開く",
"pageDescription": "VidBeeは数百のソースに到達するためにyt-dlpをバックグラウンドで使用します。",
"pageIntro": "人々が最も頻繁にダウンロードする主要サービスです。",
"pageTitle": "サポートされているサイト",
"popular": {
"bandcamp": {
"description": "インディペンデントアーティストのアルバムとコミュニティリリース。",
"label": "Bandcamp"
},
"dailymotion": {
"description": "グローバルニュース、スポーツ、エンターテイメントクリップ。",
"label": "Dailymotion"
},
"facebook": {
"description": "パブリックページからのフィード、Watch、Reels動画。",
"label": "Facebook"
},
"instagram": {
"description": "フィード、Stories、Reels、ハイライトコンテンツ。",
"label": "Instagram"
},
"kick": {
"description": "Kickプラットフォームでのクリエイターライブストリームとリプレイ。",
"label": "Kick"
},
"linkedin": {
"description": "プロフェッショナルトーク、ウェビナー、学習動画。",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJミックス、ラジオ番組、ロングフォームオーディオ。",
"label": "Mixcloud"
},
"niconico": {
"description": "日本のアニメーション、音楽、ライブ放送アーカイブ。",
"label": "Niconico"
},
"pinterest": {
"description": "アイデアピン、ハウツーReels、ライフスタイルインスピレーション動画。",
"label": "Pinterest"
},
"reddit": {
"description": "コミュニティからの埋め込みクリップとホスト動画。",
"label": "Reddit"
},
"soundcloud": {
"description": "音楽トラック、プレイリスト、DJセット。",
"label": "SoundCloud"
},
"tiktok": {
"description": "ショートフォームモバイル動画、エフェクト、ライブストリーム。",
"label": "TikTok"
},
"tumblr": {
"description": "クリエイティブショートフォームメディアとファン編集。",
"label": "Tumblr"
},
"twitch": {
"description": "ゲーミング、音楽、IRLライブストリームとVOD。",
"label": "Twitch"
},
"twitter": {
"description": "タイムラインポスト、Spaces録音、放送。",
"label": "X (Twitter)"
},
"vimeo": {
"description": "クリエイターとビジネス向け高品質動画ホスティング。",
"label": "Vimeo"
},
"youtube": {
"description": "世界中のクリエイターからのロングフォームとライブストリーム動画。",
"label": "YouTube"
},
"youtubemusic": {
"description": "公式ミュージックビデオ、アルバム、ライブパフォーマンス。",
"label": "YouTube Music"
}
},
"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": "定期購入"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "업데이트 확인",
"download": "다운로드",
"email": "이메일",
"feedback": "피드백",
"goToDownload": "다운로드 페이지로 이동",
"openRepo": "GitHub 저장소 열기",
"view": "보기",
"visit": "방문"
},
"appName": "VidBee",
"autoUpdateDescription": "백그라운드에서 새 릴리스를 자동으로 다운로드하고 설치합니다.",
"autoUpdateTitle": "자동 업데이트",
"betaProgramDescription": "다른 사람들보다 먼저 초기 빌드와 다가오는 기능을 받으세요.",
"betaProgramTitle": "미리보기 채널",
"description": "VidBee는 Electron으로 구축되고 yt-dlp로 구동되는 무료 오픈소스 다운로더입니다.",
"downloadingUpdate": "업데이트 다운로드 중",
"followAuthorActions": {
"follow": "@nexmoex 팔로우"
},
"followAuthorDescription": "VidBee의 최신 뉴스와 업데이트를 받아보세요.",
"followAuthorSupport": "X (Twitter)에서 개발자를 팔로우하여 VidBee의 최신 업데이트와 뉴스를 받아보세요.",
"followAuthorTitle": "개발자 팔로우",
"here": "여기",
"homepage": "홈페이지",
"latestVersionBadge": "최신: v{{version}}",
"latestVersionStatus": {
"available": "새 버전 사용 가능",
"error": "최신 버전을 가져올 수 없음",
"uptodate": "최신 버전 사용 중"
},
"notifications": {
"checkingUpdates": "업데이트 검색 중...",
"downloadError": "업데이트 다운로드 실패",
"downloadStarted": "다운로드 시작...",
"downloadUpdate": "업데이트 {{version}}을(를) 다운로드하고 설치하시겠습니까?",
"manualDownloadAction": "지금 다운로드",
"noUpdatesAvailable": "최신 버전을 사용 중입니다",
"restartNowAction": "지금 다시 시작",
"restartToUpdate": "지금 재시작하여 업데이트를 설치하시겠습니까?",
"unknownErrorFallback": "알 수 없는 오류",
"updateAvailable": "사용 가능한 업데이트: {{version}}",
"updateAvailableMessage": "새 버전 {{version}}을(를) 사용할 수 있습니다. \n공식 홈페이지에서 다운로드해주세요.",
"updateDownloaded": "업데이트가 다운로드되었습니다. 재시작하여 설치하세요",
"updateDownloadedVersion": "업데이트 {{version}}을(를) 다운로드했습니다. 설치하려면 다시 시작하세요.",
"updateError": "업데이트 확인 실패: {{error}}"
},
"preferencesDescription": "이 페이지를 떠나지 않고 업데이트 설정을 조정하세요.",
"preferencesTitle": "빠른 토글",
"resources": {
"changelog": "릴리스 노트",
"changelogDescription": "각 버전에서 변경된 내용을 확인하세요.",
"contact": "이메일 지원",
"contactDescription": "도움이나 협업을 위해 직접 연락하세요.",
"documentation": "도움말 센터",
"documentationDescription": "가이드, FAQ 및 일반적인 워크플로우.",
"feedback": "피드백 및 문제",
"feedbackDescription": "GitHub에서 아이디어를 공유하거나 문제를 신고하세요.",
"license": "라이선스",
"licenseDescription": "오픈소스 라이선스 조건을 검토하세요.",
"website": "공식 웹사이트",
"websiteDescription": "제품 하이라이트, 로드맵 및 커뮤니티 뉴스."
},
"resourcesDescription": "VidBee에 대해 더 알아보고 연결을 유지하는 유용한 링크입니다.",
"resourcesTitle": "리소스",
"shareActions": {
"copy": "링크 복사",
"facebook": "Facebook에서 공유",
"twitter": "X (Twitter)에서 공유"
},
"shareDescription": "한 번의 클릭으로 커뮤니티와 VidBee를 공유하세요.",
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
"shareTitle": "소문을 퍼뜨리세요",
"sourceCode": "소스 코드 사용 가능",
"title": "정보",
"version": "버전",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "다운로드 완료 시 앱 닫기",
"currentLocation": "현재 다운로드 위치 - ",
"downloadLocation": "다운로드 위치",
"downloadSubs": "사용 가능한 경우 자막 다운로드",
"end": "끝",
"endHint": "비워두면 끝까지 다운로드됩니다",
"endPlaceholder": "10:00",
"selectLocation": "다운로드 위치 선택",
"start": "시작",
"startHint": "비워두면 처음부터 시작됩니다",
"startPlaceholder": "00:00",
"subtitles": "자막",
"timeRange": "특정 시간 범위 다운로드",
"title": "고급 옵션"
},
"app": {
"description": "수백 개의 사이트에서 비디오와 오디오 다운로드",
"title": "VidBee"
},
"audioExtract": {
"bad": "나쁨",
"best": "최고",
"extract": "추출",
"good": "좋음",
"normal": "보통",
"selectFormat": "형식 선택",
"selectQuality": "품질 선택",
"title": "오디오 추출",
"worst": "최악"
},
"download": {
"active": "활성",
"all": "모두",
"audio": "오디오",
"back": "뒤로",
"cancel": "취소",
"cancelled": "취소됨",
"clearCompleted": "완료된 항목 지우기",
"clearDownloads": "다운로드 지우기",
"completed": "완료",
"downloadAudio": "오디오 다운로드",
"downloadBtn": "다운로드",
"downloadPending": "대기 중",
"downloadQueue": "다운로드 큐",
"downloadVideo": "비디오 다운로드",
"downloading": "다운로드 중...",
"enterUrl": "비디오 URL 입력",
"enterUrlDescription": "비디오 URL을 붙여넣거나 입력하세요. ",
"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": "항목을 찾을 수 없음",
"oneClickDownload": "원클릭 다운로드",
"oneClickDownloadDescription": "확인 없이 기본 설정으로 직접 다운로드",
"oneClickDownloadEnabled": "원클릭 다운로드가 활성화되었습니다. \n다운로드는 기본 설정으로 바로 시작됩니다.",
"oneClickDownloadNow": "지금 다운로드",
"oneClickDownloadStarted": "기본 설정으로 다운로드 시작됨",
"paste": "붙여넣기",
"pastePlaylistUrl": "클립보드에서 재생목록 링크 붙여넣기 [Ctrl + V]",
"pasteUrl": "비디오 URL 또는 ID 붙여넣기 [Ctrl + V]",
"preparing": "준비 중...",
"processing": "처리 중",
"progress": "진행률",
"selectAudioFormat": "오디오 형식 선택",
"selectFormat": "형식 선택",
"selectVideoFormat": "비디오 형식 선택",
"showDetails": "세부정보 표시",
"singleVideo": "단일 비디오",
"speed": "속도",
"title": "제목",
"total": "총계",
"unknownQuality": "알 수 없는 품질",
"unknownSize": "알 수 없는 크기",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "비디오",
"videoInfo": "비디오 정보",
"videoInfoUpdated": "비디오 정보 업데이트됨"
},
"errors": {
"clickToCopy": "세부 정보 복사하려면 클릭",
"clipboardEmpty": "클립보드가 비어있음",
"downloadFailed": "다운로드 실패",
"downloadNecessaryFilesFailed": "필수 파일 다운로드 실패. 네트워크를 확인하고 다시 시도하세요",
"emptyUrl": "URL을 입력하세요",
"errorDetails": "오류 세부 정보",
"fetchInfoFailed": "비디오 정보 가져오기 실패",
"networkError": "오류가 발생했습니다. 네트워크를 확인하고 올바른 URL을 사용하세요",
"pasteFromClipboard": "클립보드에서 붙여넣기 실패"
},
"history": {
"clearCancelled": "취소된 항목 지우기",
"clearCompleted": "완료된 항목 지우기",
"clearErrors": "오류 지우기",
"copyToClipboard": "클립보드에 복사",
"copyUrl": "URL 복사",
"date": "날짜",
"description": "다운로드 기록 보기 및 관리",
"duration": "지속 시간",
"fileSize": "파일 크기",
"filters": {
"all": "모두",
"cancelled": "취소됨",
"completed": "완료",
"errors": "오류"
},
"noHistory": "다운로드 기록이 아직 없습니다",
"noHistoryDescription": "완료된 다운로드가 여기에 표시됩니다",
"openDownloadFolder": "다운로드 폴더 열기",
"openFile": "파일 열기",
"openFileLocation": "파일 위치 열기",
"openFolder": "폴더 열기",
"openInBrowser": "브라우저에서 열려면 클릭",
"removeItem": "항목 제거",
"stats": {
"cancelled": "취소됨",
"completed": "완료",
"errors": "오류",
"total": "총계"
},
"status": {
"cancelled": "취소됨",
"completed": "완료",
"error": "오류"
},
"title": "다운로드 기록"
},
"menu": {
"about": "정보",
"download": "다운로드",
"playlist": "재생목록 다운로드",
"preferences": "환경설정",
"rss": "RSS",
"subscriptions": "구독",
"supportedSites": "지원되는 사이트",
"theme": "테마:"
},
"notifications": {
"copyFailed": "클립보드에 복사 실패",
"downloadCompleted": "다운로드 완료",
"downloadFailed": "다운로드 실패",
"downloadStarted": "다운로드 시작됨",
"itemRemoved": "항목 제거됨",
"openFileFailed": "파일 열기 실패",
"openFolderFailed": "폴더 열기 실패",
"removeFailed": "항목 제거 실패",
"settingsSaved": "설정 저장됨",
"urlCopied": "URL이 클립보드에 복사됨",
"videoCopied": "비디오가 클립보드에 복사됨"
},
"playlist": {
"badgeLabel": "재생목록",
"clearPreview": "미리보기 지우기",
"comingSoon": "재생목록 다운로드 기능이 곧 출시됩니다!",
"completed": "재생목록 다운로드됨",
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
"downloadFailed": "재생목록 다운로드 시작 실패",
"downloadPlaylist": "재생목록 다운로드",
"downloadStarted": "재생목록에서 {{count}}개 비디오 다운로드 시작됨",
"downloadType": "다운로드 유형",
"downloading": "재생목록 다운로드 중:",
"endIndex": "끝",
"enterPlaylistUrl": "재생목록 URL 입력",
"fetchFailed": "재생목록 정보 가져오기 실패",
"filenameFormat": "재생목록용 파일명 형식",
"folderFormat": "재생목록용 폴더명 형식",
"foundVideos": "재생목록에서 {{count}}개 비디오 발견",
"groupActive": "{{count}} 활성",
"groupErrors": "{{count}}개 실패",
"groupSummary": "{{완료}} / {{총}} 완료",
"linkLabel": "재생목록 URL",
"noEntries": "이 재생목록에는 동영상이 없습니다.",
"noEntriesInRange": "선택한 범위에 동영상이 없습니다.",
"noRangeSelected": "종료 설정 없음 - 전체 재생목록이 선택됨",
"playlistUrlDescription": "재생목록의 모든 비디오를 일괄 다운로드",
"positionLabel": "{{total}}개 항목 중 {{index}}개 항목",
"previewButton": "미리보기 재생목록",
"previewFailed": "재생목록을 미리 볼 수 없습니다.",
"previewRequired": "다운로드하기 전에 재생 목록을 미리 봅니다.",
"previewSummary": "다운로드하기 전에 재생 목록 항목을 미리 봅니다.",
"range": "범위 (선택사항)",
"resetToDefault": "기본값으로 재설정",
"selectedRange": "범위: {{start}}-{{end}}",
"showingCount": "{{count}}개의 동영상 표시 중",
"startIndex": "시작 (1)",
"title": "재생목록 다운로드",
"totalVideos": "총 동영상 수: {{count}}",
"untitled": "제목 없는 재생목록"
},
"settings": {
"aboutTab": "정보",
"advanced": "고급",
"app": "앱 설정",
"audio": "오디오 환경설정",
"browserForCookies": "쿠키를 사용할 브라우저 선택",
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"clearConfigFile": "분명한",
"clearCookiesFile": "분명한",
"configFile": "설정 파일 사용",
"configFileDescription": "yt-dlp용 사용자 정의 설정 파일",
"cookiesFile": "쿠키 파일",
"cookiesFileDescription": "인증을 위해 로드할 Netscape 형식의 쿠키 파일",
"cookiesHelpBrowser": "로그인된 세션을 자동으로 재사용하려면 위에서 브라우저를 선택하세요.",
"cookiesHelpFaq": "yt-dlp 쿠키 FAQ 열기",
"cookiesHelpFile": "Netscape 쿠키 파일을 내보내고(yt-dlp FAQ 참조) 필요할 때 여기에서 선택하세요.",
"cookiesHelpTitle": "쿠키 사용",
"dark": "다크",
"description": "다운로드 환경설정 및 앱 설정 구성",
"directorySelectError": "디렉토리 선택 실패",
"downloadPath": "다운로드 위치",
"downloadPathDescription": "다운로드 파일을 저장할 위치 선택",
"enableAnalytics": "VidBee 개선에 참여해주세요",
"enableAnalyticsDescription": "익명의 사용 데이터를 공유하면 앱이 어떻게 사용되는지 이해하고 개선 우선순위를 정하는 데 도움이 됩니다.",
"fileSelectError": "파일 선택 실패",
"general": "일반",
"hideDockIcon": "Dock 아이콘 숨기기",
"hideDockIconDescription": "macOS Dock에서 VidBee를 제거합니다. \n메뉴 표시줄이나 트레이 아이콘을 사용하여 앱을 다시 엽니다.",
"language": "언어",
"launchAtLogin": "시작 시 실행",
"launchAtLoginDescription": "컴퓨터에 로그인한 후 자동으로 VidBee를 엽니다.",
"launchAtLoginUnsupported": "자동 실행은 macOS 및 Windows에서만 사용할 수 있습니다.",
"light": "라이트",
"maxConcurrentDownloads": "최대 활성 다운로드 수",
"maxConcurrentDownloadsDescription": "최대 동시 다운로드 수",
"none": "없음",
"oneClickDownload": "원클릭 다운로드",
"oneClickDownloadDescription": "기본 설정으로 원클릭 다운로드 활성화",
"oneClickDownloadType": "기본 다운로드 유형",
"oneClickDownloadTypeDescription": "원클릭 다운로드의 기본 다운로드 유형을 선택하세요. 품질은 아래 사전 설정을 사용합니다.",
"oneClickQuality": "선호 품질",
"oneClickQualityDescription": "원클릭 다운로드에 사용되는 품질 사전 설정을 선택하세요",
"oneClickQualityOptions": {
"auto": "자동",
"bad": "나쁨",
"best": "최고",
"good": "좋음",
"normal": "보통",
"worst": "최악"
},
"openLinkError": "링크를 열지 못했습니다.",
"proxy": "프록시",
"proxyDescription": "네트워크 요청용 프록시 서버",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "설정 파일 선택",
"selectPath": "선택",
"showMoreFormats": "더 많은 형식 옵션 표시",
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
"subscriptionDefaults": {
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
},
"system": "시스템",
"theme": "테마",
"themeDescription": "VidBee용 라이트, 다크 또는 시스템 테마 선택",
"title": "설정",
"tray": {
"quit": "종료",
"showHome": "홈 표시"
},
"video": "비디오 환경설정"
},
"sites": {
"homeInlineDescription": "{{sites}} 및 더 많은 사이트를 지원합니다.",
"moreDescription": "완전한 yt-dlp 목록은 커뮤니티에 의해 지속적으로 업데이트됩니다.",
"moreTitle": "다른 사이트가 필요하신가요?",
"openFullList": "지원되는 모든 사이트 목록 열기",
"pageDescription": "VidBee는 수백 개의 소스에 도달하기 위해 yt-dlp를 백그라운드에서 사용합니다.",
"pageIntro": "사람들이 가장 자주 다운로드하는 주요 서비스들입니다.",
"pageTitle": "지원되는 사이트",
"popular": {
"bandcamp": {
"description": "인디 아티스트 앨범 및 커뮤니티 릴리스.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "글로벌 뉴스, 스포츠 및 엔터테인먼트 클립.",
"label": "Dailymotion"
},
"facebook": {
"description": "공개 페이지의 피드, Watch 및 Reels 비디오.",
"label": "Facebook"
},
"instagram": {
"description": "피드, Stories, Reels 및 Highlights 콘텐츠.",
"label": "Instagram"
},
"kick": {
"description": "Kick 플랫폼의 크리에이터 라이브 스트림 및 리플레이.",
"label": "Kick"
},
"linkedin": {
"description": "전문 강연, 웨비나 및 학습 비디오.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ 믹스, 라디오 쇼 및 롱폼 오디오.",
"label": "Mixcloud"
},
"niconico": {
"description": "일본 애니메이션, 음악 및 라이브 방송 아카이브.",
"label": "Niconico"
},
"pinterest": {
"description": "아이디어 핀, 하우투 Reels 및 라이프스타일 영감 비디오.",
"label": "Pinterest"
},
"reddit": {
"description": "커뮤니티의 임베드 클립 및 호스팅 비디오.",
"label": "Reddit"
},
"soundcloud": {
"description": "음악 트랙, 플레이리스트 및 DJ 세트.",
"label": "SoundCloud"
},
"tiktok": {
"description": "짧은 모바일 비디오, 효과 및 라이브 스트림.",
"label": "TikTok"
},
"tumblr": {
"description": "창의적인 짧은 미디어 및 팬 편집.",
"label": "Tumblr"
},
"twitch": {
"description": "게임, 음악 및 IRL 라이브 스트림 및 VOD.",
"label": "Twitch"
},
"twitter": {
"description": "타임라인 포스트, Spaces 녹음 및 방송.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "크리에이터 및 비즈니스용 고품질 비디오 호스팅.",
"label": "Vimeo"
},
"youtube": {
"description": "전 세계 크리에이터의 롱폼 및 라이브스트림 비디오.",
"label": "YouTube"
},
"youtubemusic": {
"description": "공식 뮤직 비디오, 앨범 및 라이브 공연.",
"label": "YouTube Music"
}
},
"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": "구독"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Verificar atualizações",
"download": "Download",
"email": "Email",
"feedback": "Feedback",
"goToDownload": "Vá para a página de download",
"openRepo": "Abrir repositório GitHub",
"view": "Ver",
"visit": "Visitar"
},
"appName": "VidBee",
"autoUpdateDescription": "Baixar e instalar novas versões automaticamente em segundo plano.",
"autoUpdateTitle": "Atualizações automáticas",
"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"
},
"followAuthorDescription": "Mantenha-se atualizado com as últimas notícias e atualizações do VidBee.",
"followAuthorSupport": "Siga o desenvolvedor no X (Twitter) para obter as últimas atualizações e notícias sobre VidBee.",
"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",
"downloadStarted": "Download iniciado...",
"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",
"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}}"
},
"preferencesDescription": "Ajuste configurações de atualização sem sair desta página.",
"preferencesTitle": "Alternâncias Rápidas",
"resources": {
"changelog": "Notas da versão",
"changelogDescription": "Acompanhe o que mudou em cada versão.",
"contact": "Suporte por email",
"contactDescription": "Entre em contato diretamente para ajuda ou colaboração.",
"documentation": "Central de ajuda",
"documentationDescription": "Guias, FAQs e fluxos de trabalho comuns.",
"feedback": "Feedback e problemas",
"feedbackDescription": "Compartilhe ideias ou reporte problemas no GitHub.",
"license": "Licença",
"licenseDescription": "Revise os termos da licença de código aberto.",
"website": "Site oficial",
"websiteDescription": "Destaques do produto, roadmap e notícias da comunidade."
},
"resourcesDescription": "Links úteis para aprender mais sobre VidBee e manter-se conectado.",
"resourcesTitle": "Recursos",
"shareActions": {
"copy": "Copiar link",
"facebook": "Compartilhar no Facebook",
"twitter": "Compartilhar no X (Twitter)"
},
"shareDescription": "Compartilhe VidBee com sua comunidade em um clique.",
"shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.",
"shareTitle": "Espalhe a palavra",
"sourceCode": "Código fonte disponível",
"title": "Sobre",
"version": "Versão",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "Fechar aplicativo quando download terminar",
"currentLocation": "Local de download atual - ",
"downloadLocation": "Local de download",
"downloadSubs": "Baixar legendas se disponíveis",
"end": "Fim",
"endHint": "Se deixado vazio, será baixado até o final",
"endPlaceholder": "10:00",
"selectLocation": "Selecionar Local de Download",
"start": "Início",
"startHint": "Se deixado vazio, começará do início",
"startPlaceholder": "00:00",
"subtitles": "Legendas",
"timeRange": "Baixar intervalo de tempo específico",
"title": "Opções Avançadas"
},
"app": {
"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",
"audio": "Áudio",
"back": "Voltar",
"cancel": "Cancelar",
"cancelled": "Cancelado",
"clearCompleted": "Limpar Concluídos",
"clearDownloads": "Limpar Downloads",
"completed": "Concluído",
"downloadAudio": "Baixar Áudio",
"downloadBtn": "Baixar",
"downloadPending": "Pendente",
"downloadQueue": "Fila de Download",
"downloadVideo": "Baixar Vídeo",
"downloading": "Baixando...",
"enterUrl": "Inserir URL do Vídeo",
"enterUrlDescription": "Cole ou digite uma URL de vídeo. ",
"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",
"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.",
"oneClickDownloadNow": "Baixar Agora",
"oneClickDownloadStarted": "Download iniciado com configurações padrão",
"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]",
"preparing": "Preparando...",
"processing": "Processando",
"progress": "Progresso",
"selectAudioFormat": "Selecionar Formato de Áudio",
"selectFormat": "Selecionar Formato",
"selectVideoFormat": "Selecionar Formato de Vídeo",
"showDetails": "Mostrar detalhes",
"singleVideo": "Vídeo Único",
"speed": "Velocidade",
"title": "Título",
"total": "Total",
"unknownQuality": "Qualidade desconhecida",
"unknownSize": "Tamanho desconhecido",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Vídeo",
"videoInfo": "Informações do Vídeo",
"videoInfoUpdated": "Informações do vídeo atualizadas"
},
"errors": {
"clickToCopy": "Clique para copiar detalhes",
"clipboardEmpty": "Área de transferência vazia",
"downloadFailed": "Download falhou",
"downloadNecessaryFilesFailed": "Falha ao baixar arquivos necessários. Verifique sua rede e tente novamente",
"emptyUrl": "Por favor, insira uma URL",
"errorDetails": "Detalhes do Erro",
"fetchInfoFailed": "Falha ao buscar informações do vídeo",
"networkError": "Algum erro ocorreu. Verifique sua rede e use uma URL correta",
"pasteFromClipboard": "Falha ao colar da área de transferência"
},
"history": {
"clearCancelled": "Limpar Cancelados",
"clearCompleted": "Limpar Concluídos",
"clearErrors": "Limpar Erros",
"copyToClipboard": "Copiar para área de transferência",
"copyUrl": "Copiar URL",
"date": "Data",
"description": "Ver e gerenciar seu histórico de downloads",
"duration": "Duração",
"fileSize": "Tamanho do Arquivo",
"filters": {
"all": "Todos",
"cancelled": "Cancelado",
"completed": "Concluído",
"errors": "Erros"
},
"noHistory": "Nenhum histórico de download ainda",
"noHistoryDescription": "Seus downloads concluídos aparecerão aqui",
"openDownloadFolder": "Abrir Pasta de Downloads",
"openFile": "Abrir Arquivo",
"openFileLocation": "Abrir Localização do Arquivo",
"openFolder": "Abrir Pasta",
"openInBrowser": "Clique para abrir no navegador",
"removeItem": "Remover Item",
"stats": {
"cancelled": "Cancelado",
"completed": "Concluído",
"errors": "Erros",
"total": "Total"
},
"status": {
"cancelled": "Cancelado",
"completed": "Concluído",
"error": "Erro"
},
"title": "Histórico de Downloads"
},
"menu": {
"about": "Sobre",
"download": "Download",
"playlist": "Baixar Playlist",
"preferences": "Preferências",
"rss": "RSS",
"subscriptions": "Assinaturas",
"supportedSites": "Sites Suportados",
"theme": "Tema:"
},
"notifications": {
"copyFailed": "Falha ao copiar para área de transferência",
"downloadCompleted": "Download concluído",
"downloadFailed": "Download falhou",
"downloadStarted": "Download iniciado",
"itemRemoved": "Item removido",
"openFileFailed": "Falha ao abrir arquivo",
"openFolderFailed": "Falha ao abrir pasta",
"removeFailed": "Falha ao remover item",
"settingsSaved": "Configurações salvas",
"urlCopied": "URL copiada para área de transferência",
"videoCopied": "Vídeo copiado para área de transferência"
},
"playlist": {
"badgeLabel": "Lista de reprodução",
"clearPreview": "Limpar visualização",
"comingSoon": "Recurso de download de playlist em breve!",
"completed": "Playlist baixada",
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
"downloadFailed": "Falha ao iniciar download da playlist",
"downloadPlaylist": "Baixar Playlist",
"downloadStarted": "Iniciado download de {{count}} vídeos da playlist",
"downloadType": "Tipo de Download",
"downloading": "Baixando playlist:",
"endIndex": "Fim",
"enterPlaylistUrl": "Inserir URL da Playlist",
"fetchFailed": "Falha ao buscar informações da playlist",
"filenameFormat": "Formato de nome de arquivo para playlists",
"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",
"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}}",
"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.",
"range": "Intervalo (Opcional)",
"resetToDefault": "Redefinir para padrão",
"selectedRange": "Intervalo: {{início}}-{{fim}}",
"showingCount": "Exibindo {{count}} vídeos",
"startIndex": "Início (1)",
"title": "Baixar Playlist",
"totalVideos": "Total de vídeos: {{count}}",
"untitled": "Playlist sem título"
},
"settings": {
"aboutTab": "Sobre",
"advanced": "Avançado",
"app": "Configurações do App",
"audio": "Preferências de Áudio",
"browserForCookies": "Selecionar navegador para usar cookies",
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"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",
"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",
"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",
"maxConcurrentDownloads": "Número máximo de downloads ativos",
"maxConcurrentDownloadsDescription": "Número máximo de downloads simultâneos",
"none": "Nenhum",
"oneClickDownload": "Download de Um Clique",
"oneClickDownloadDescription": "Habilitar download de um clique com configurações padrão",
"oneClickDownloadType": "Tipo de download padrão",
"oneClickDownloadTypeDescription": "Escolha o tipo de download padrão para downloads de um clique. A qualidade usa o preset abaixo.",
"oneClickQuality": "Qualidade preferida",
"oneClickQualityDescription": "Selecione o preset de qualidade usado para downloads de um clique",
"oneClickQualityOptions": {
"auto": "Automático",
"bad": "Ruim",
"best": "Melhor",
"good": "Bom",
"normal": "Normal",
"worst": "Pior"
},
"openLinkError": "Falha ao abrir o link",
"proxy": "Proxy",
"proxyDescription": "Servidor proxy para requisições de rede",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Selecionar arquivo de configuração",
"selectPath": "Selecionar",
"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)."
},
"system": "Sistema",
"theme": "Tema",
"themeDescription": "Escolha um tema claro, escuro ou sistema para VidBee",
"title": "Configurações",
"tray": {
"quit": "Sair",
"showHome": "Mostrar Início"
},
"video": "Preferências de Vídeo"
},
"sites": {
"homeInlineDescription": "Suporta {{sites}} e mais.",
"moreDescription": "A lista completa do yt-dlp é atualizada constantemente pela comunidade.",
"moreTitle": "Precisa de outro site?",
"openFullList": "Abrir lista completa de sites suportados",
"pageDescription": "VidBee usa yt-dlp nos bastidores para alcançar centenas de fontes.",
"pageIntro": "Aqui estão os serviços principais que as pessoas mais baixam.",
"pageTitle": "Sites Suportados",
"popular": {
"bandcamp": {
"description": "Álbuns de artistas independentes e lançamentos da comunidade.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Notícias globais, esportes e clipes de entretenimento.",
"label": "Dailymotion"
},
"facebook": {
"description": "Vídeos de feed, Watch e Reels de páginas públicas.",
"label": "Facebook"
},
"instagram": {
"description": "Conteúdo de feed, Stories, Reels e Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Streams ao vivo e replays de criadores na plataforma Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Palestras profissionais, webinars e vídeos de aprendizado.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "Mixes de DJ, programas de rádio e áudio long-form.",
"label": "Mixcloud"
},
"niconico": {
"description": "Animação japonesa, música e arquivo de transmissão ao vivo.",
"label": "Niconico"
},
"pinterest": {
"description": "Pins de ideias, Reels de tutoriais e vídeos de inspiração lifestyle.",
"label": "Pinterest"
},
"reddit": {
"description": "Clipes incorporados e vídeos hospedados das comunidades.",
"label": "Reddit"
},
"soundcloud": {
"description": "Faixas musicais, playlists e sets de DJ.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Vídeos curtos móveis, efeitos e streams ao vivo.",
"label": "TikTok"
},
"tumblr": {
"description": "Mídia curta criativa e edições de fãs.",
"label": "Tumblr"
},
"twitch": {
"description": "Streams ao vivo de gaming, música e IRL e VOD.",
"label": "Twitch"
},
"twitter": {
"description": "Posts de timeline, gravações Spaces e transmissões.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Hospedagem de vídeo de alta qualidade para criadores e empresas.",
"label": "Vimeo"
},
"youtube": {
"description": "Vídeo long-form e livestream de criadores em todo o mundo.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Vídeos musicais oficiais, álbuns e performances ao vivo.",
"label": "YouTube Music"
}
},
"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"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "Проверить обновления",
"download": "Скачать",
"email": "Email",
"feedback": "Обратная связь",
"goToDownload": "Перейти на страницу загрузки",
"openRepo": "Открыть репозиторий GitHub",
"view": "Просмотр",
"visit": "Посетить"
},
"appName": "VidBee",
"autoUpdateDescription": "Автоматически загружать и устанавливать новые версии в фоновом режиме.",
"autoUpdateTitle": "Автообновления",
"betaProgramDescription": "Получайте ранние сборки и предстоящие функции раньше всех.",
"betaProgramTitle": "Канал предпросмотра",
"description": "VidBee — это бесплатный загрузчик с открытым исходным кодом, созданный на Electron и работающий на yt-dlp.",
"followAuthorActions": {
"follow": "Подписаться на @nexmoex"
},
"followAuthorDescription": "Будьте в курсе последних новостей и обновлений VidBee.",
"followAuthorSupport": "Подпишитесь на разработчика в X (Twitter), чтобы получать последние обновления и новости о VidBee.",
"followAuthorTitle": "Подписаться на разработчика",
"here": "здесь",
"homepage": "Главная страница",
"notifications": {
"checkingUpdates": "Поиск обновлений...",
"downloadError": "Не удалось загрузить обновление",
"downloadStarted": "Загрузка началась...",
"downloadUpdate": "Загрузить и установить обновление {{version}}?",
"manualDownloadAction": "Скачать сейчас",
"noUpdatesAvailable": "Вы используете последнюю версию",
"restartToUpdate": "Перезапустить сейчас для установки обновления?",
"restartNowAction": "Перезапустить сейчас",
"updateAvailable": "Доступно обновление: {{version}}",
"updateAvailableMessage": "Доступна новая версия {{version}}. Пожалуйста, загрузите её с официального сайта.",
"updateDownloaded": "Обновление загружено, перезапустите для установки",
"updateDownloadedVersion": "Обновление {{version}} загружено, перезапустите для установки",
"updateError": "Не удалось проверить обновления: {{error}}",
"unknownErrorFallback": "Неизвестная ошибка"
},
"preferencesDescription": "Настройте параметры обновления, не покидая эту страницу.",
"preferencesTitle": "Быстрые переключатели",
"resources": {
"changelog": "Примечания к выпуску",
"changelogDescription": "Узнайте, что изменилось в каждой версии.",
"contact": "Поддержка по email",
"contactDescription": "Свяжитесь напрямую для получения помощи или сотрудничества.",
"documentation": "Центр помощи",
"documentationDescription": "Руководства, FAQ и общие рабочие процессы.",
"feedback": "Обратная связь и проблемы",
"feedbackDescription": "Поделитесь идеями или сообщите о проблемах на GitHub.",
"license": "Лицензия",
"licenseDescription": "Ознакомьтесь с условиями лицензии с открытым исходным кодом.",
"website": "Официальный сайт",
"websiteDescription": "Основные моменты продукта, дорожная карта и новости сообщества."
},
"resourcesDescription": "Полезные ссылки для получения дополнительной информации о VidBee и поддержания связи.",
"resourcesTitle": "Ресурсы",
"shareActions": {
"copy": "Копировать ссылку",
"facebook": "Поделиться в Facebook",
"twitter": "Поделиться в X (Twitter)"
},
"shareDescription": "Поделитесь VidBee с вашим сообществом одним кликом.",
"shareSupport": "Рекомендуйте VidBee своим друзьям, чтобы поддержать наш рост и обновления.",
"shareTitle": "Распространяйте информацию",
"sourceCode": "Исходный код доступен",
"title": "О программе",
"version": "Версия",
"versionLabel": "v{{version}}",
"latestVersionBadge": "Последняя: v{{version}}",
"latestVersionStatus": {
"available": "Доступна новая версия",
"uptodate": "У вас актуальная версия",
"error": "Не удалось получить последнюю версию"
},
"downloadingUpdate": "Загрузка обновления"
},
"advancedOptions": {
"closeWhenDone": "Закрыть приложение после завершения загрузки",
"currentLocation": "Текущее местоположение загрузки - ",
"downloadLocation": "Местоположение загрузки",
"downloadSubs": "Загрузить субтитры, если доступны",
"end": "Конец",
"endHint": "Если оставить пустым, будет загружено до конца",
"endPlaceholder": "10:00",
"selectLocation": "Выбрать местоположение загрузки",
"start": "Начало",
"startHint": "Если оставить пустым, начнётся с начала",
"startPlaceholder": "00:00",
"subtitles": "Субтитры",
"timeRange": "Загрузить определённый временной диапазон",
"title": "Дополнительные параметры"
},
"app": {
"description": "Загружайте видео и аудио с сотен сайтов",
"title": "VidBee"
},
"audioExtract": {
"bad": "Плохое",
"best": "Лучшее",
"extract": "Извлечь",
"good": "Хорошее",
"normal": "Обычное",
"selectFormat": "Выбрать формат",
"selectQuality": "Выбрать качество",
"title": "Извлечь аудио",
"worst": "Худшее"
},
"download": {
"active": "Активные",
"all": "Все",
"audio": "Аудио",
"back": "Назад",
"cancel": "Отмена",
"cancelled": "Отменено",
"clearCompleted": "Очистить завершённые",
"clearDownloads": "Очистить загрузки",
"completed": "Завершено",
"downloadAudio": "Загрузить аудио",
"downloadBtn": "Загрузить",
"downloadPending": "Ожидание",
"downloadQueue": "Очередь загрузки",
"downloadVideo": "Загрузить видео",
"downloading": "Загрузка...",
"enterUrl": "Введите URL видео",
"enterUrlDescription": "Вставьте или введите URL видео. ",
"error": "Ошибка",
"fetch": "Получить",
"fetchingVideoInfo": "Получение информации о видео...",
"history": "История",
"imageLoadError": "Не удалось загрузить изображение",
"imagePlaceholder": "Изображение недоступно",
"infoUnavailable": "Загрузка одним кликом (Информация недоступна)",
"loading": "Загрузка",
"moreOptions": "Дополнительные параметры",
"noActiveDownloads": "Нет активных загрузок",
"noAudio": "Нет аудио",
"noHistory": "Нет истории загрузок",
"noItems": "Элементы не найдены",
"goToSettings": "Перейти в настройки",
"oneClickDownload": "Загрузка одним кликом",
"oneClickDownloadDescription": "Загрузить напрямую с настройками по умолчанию без подтверждения",
"oneClickDownloadEnabled": "Загрузка одним кликом включена. Загрузки будут начинаться напрямую с настройками по умолчанию.",
"oneClickDownloadNow": "Загрузить сейчас",
"oneClickDownloadStarted": "Загрузка начата с настройками по умолчанию",
"paste": "Вставить",
"pastePlaylistUrl": "Нажмите, чтобы вставить ссылку на плейлист из буфера обмена [Ctrl + V]",
"pasteUrl": "Нажмите, чтобы вставить URL видео или ID [Ctrl + V]",
"preparing": "Подготовка...",
"processing": "Обработка",
"progress": "Прогресс",
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали",
"selectAudioFormat": "Выбрать формат аудио",
"selectFormat": "Выбрать формат",
"selectVideoFormat": "Выбрать формат видео",
"singleVideo": "Одно видео",
"speed": "Скорость",
"title": "Название",
"total": "Всего",
"unknownQuality": "Неизвестное качество",
"unknownSize": "Неизвестный размер",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Видео",
"videoInfo": "Информация о видео",
"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": "Подписка"
}
},
"errors": {
"clickToCopy": "Нажмите, чтобы скопировать детали",
"clipboardEmpty": "Буфер обмена пуст",
"downloadFailed": "Загрузка не удалась",
"downloadNecessaryFilesFailed": "Не удалось загрузить необходимые файлы. Пожалуйста, проверьте вашу сеть и попробуйте снова",
"emptyUrl": "Пожалуйста, введите URL",
"errorDetails": "Детали ошибки",
"fetchInfoFailed": "Не удалось получить информацию о видео",
"networkError": "Произошла ошибка. Проверьте вашу сеть и используйте правильный URL",
"pasteFromClipboard": "Не удалось вставить из буфера обмена"
},
"history": {
"clearCancelled": "Очистить отменённые",
"clearCompleted": "Очистить завершённые",
"clearErrors": "Очистить ошибки",
"copyToClipboard": "Копировать в буфер обмена",
"copyUrl": "Копировать URL",
"date": "Дата",
"description": "Просмотр и управление историей загрузок",
"duration": "Длительность",
"fileSize": "Размер файла",
"filters": {
"all": "Все",
"cancelled": "Отменённые",
"completed": "Завершённые",
"errors": "Ошибки"
},
"noHistory": "Истории загрузок пока нет",
"noHistoryDescription": "Ваши завершённые загрузки появятся здесь",
"openDownloadFolder": "Открыть папку загрузок",
"openFile": "Открыть файл",
"openFileLocation": "Открыть местоположение файла",
"openFolder": "Открыть папку",
"openInBrowser": "Нажмите, чтобы открыть в браузере",
"removeItem": "Удалить элемент",
"stats": {
"cancelled": "Отменённые",
"completed": "Завершённые",
"errors": "Ошибки",
"total": "Всего"
},
"status": {
"cancelled": "Отменено",
"completed": "Завершено",
"error": "Ошибка"
},
"title": "История загрузок"
},
"menu": {
"about": "О программе",
"download": "Загрузить",
"playlist": "Загрузить плейлист",
"rss": "RSS",
"subscriptions": "Подписки",
"preferences": "Настройки",
"supportedSites": "Поддерживаемые сайты",
"theme": "Тема:"
},
"notifications": {
"copyFailed": "Не удалось скопировать в буфер обмена",
"downloadCompleted": "Загрузка завершена",
"downloadFailed": "Загрузка не удалась",
"downloadStarted": "Загрузка началась",
"itemRemoved": "Элемент удалён",
"openFileFailed": "Не удалось открыть файл",
"openFolderFailed": "Не удалось открыть папку",
"removeFailed": "Не удалось удалить элемент",
"settingsSaved": "Настройки сохранены",
"urlCopied": "URL скопирован в буфер обмена",
"videoCopied": "Видео скопировано в буфер обмена"
},
"playlist": {
"badgeLabel": "Плейлист",
"clearPreview": "Очистить предпросмотр",
"comingSoon": "Функция загрузки плейлиста скоро появится!",
"completed": "Плейлист загружен",
"description": "Загрузить все видео из плейлиста или канала YouTube",
"downloadFailed": "Не удалось начать загрузку плейлиста",
"downloadPlaylist": "Загрузить плейлист",
"downloadStarted": "Начата загрузка {{count}} видео из плейлиста",
"downloadType": "Тип загрузки",
"downloading": "Загрузка плейлиста:",
"endIndex": "Конец",
"enterPlaylistUrl": "Введите URL плейлиста",
"fetchFailed": "Не удалось получить информацию о плейлисте",
"filenameFormat": "Формат имени файла для плейлистов",
"folderFormat": "Формат имени папки для плейлистов",
"foundVideos": "Найдено {{count}} видео в плейлисте",
"groupActive": "{{count}} активных",
"groupErrors": "{{count}} ошибок",
"groupSummary": "{{completed}} / {{total}} завершено",
"linkLabel": "URL плейлиста",
"noEntries": "В этом плейлисте не найдено видео",
"noEntriesInRange": "Нет видео в выбранном диапазоне",
"noRangeSelected": "Конец не установлен - выбран весь плейлист",
"playlistUrlDescription": "Загрузить все видео из плейлиста массово",
"positionLabel": "Элемент {{index}} из {{total}}",
"previewButton": "Предпросмотр плейлиста",
"previewFailed": "Не удалось предпросмотреть плейлист",
"previewSummary": "Предпросмотр элементов плейлиста перед загрузкой.",
"previewRequired": "Предпросмотрите плейлист перед загрузкой.",
"range": "Диапазон (необязательно)",
"resetToDefault": "Сбросить на значения по умолчанию",
"selectedRange": "Диапазон: {{start}}-{{end}}",
"showingCount": "Показано {{count}} видео",
"startIndex": "Начало (1)",
"title": "Загрузить плейлист",
"totalVideos": "Всего видео: {{count}}",
"untitled": "Плейлист без названия"
},
"settings": {
"aboutTab": "О программе",
"advanced": "Дополнительно",
"app": "Настройки приложения",
"audio": "Настройки аудио",
"browserForCookies": "Выбрать браузер для использования cookie",
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
"cookiesFile": "Файл cookie",
"cookiesFileDescription": "Файл cookie в формате Netscape для загрузки для аутентификации",
"clearCookiesFile": "Очистить",
"cookiesHelpTitle": "Использование cookie",
"cookiesHelpBrowser": "Выберите ваш браузер выше, чтобы автоматически использовать его сеанс входа.",
"cookiesHelpFile": "Экспортируйте файл cookie Netscape (см. FAQ yt-dlp) и выберите его здесь при необходимости.",
"cookiesHelpFaq": "Открыть FAQ по cookie yt-dlp",
"openLinkError": "Не удалось открыть ссылку",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"configFile": "Использовать файл конфигурации",
"configFileDescription": "Пользовательский файл конфигурации для yt-dlp",
"clearConfigFile": "Очистить",
"dark": "Тёмная",
"description": "Настройте ваши предпочтения загрузки и настройки приложения",
"directorySelectError": "Не удалось выбрать директорию",
"downloadPath": "Местоположение загрузки",
"downloadPathDescription": "Выберите, где сохранять загруженные файлы",
"fileSelectError": "Не удалось выбрать файл",
"general": "Общие",
"language": "Язык",
"light": "Светлая",
"hideDockIcon": "Скрыть иконку Dock",
"hideDockIconDescription": "Удалить VidBee из Dock macOS. Используйте строку меню или иконку в трее, чтобы снова открыть приложение.",
"launchAtLogin": "Запускать при входе",
"launchAtLoginDescription": "Автоматически открывать VidBee после входа в систему.",
"launchAtLoginUnsupported": "Автозапуск доступен только в macOS и Windows.",
"enableAnalytics": "Помочь улучшить VidBee",
"enableAnalyticsDescription": "Поделитесь анонимными данными об использовании, чтобы помочь нам понять, как используется приложение, и расставить приоритеты улучшений.",
"maxConcurrentDownloads": "Максимальное количество активных загрузок",
"maxConcurrentDownloadsDescription": "Максимальное количество одновременных загрузок",
"none": "Нет",
"oneClickDownload": "Загрузка одним кликом",
"oneClickDownloadDescription": "Включить загрузку одним кликом с настройками по умолчанию",
"oneClickDownloadType": "Тип загрузки по умолчанию",
"oneClickDownloadTypeDescription": "Выберите тип загрузки по умолчанию для загрузок одним кликом. Качество использует предустановку ниже.",
"oneClickQuality": "Предпочтительное качество",
"oneClickQualityDescription": "Выберите предустановку качества, используемую для загрузок одним кликом",
"oneClickQualityOptions": {
"auto": "Авто",
"bad": "Плохое",
"best": "Лучшее",
"good": "Хорошее",
"normal": "Обычное",
"worst": "Худшее"
},
"proxy": "Прокси",
"proxyDescription": "Прокси-сервер для сетевых запросов",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "Выбрать файл конфигурации",
"selectPath": "Выбрать",
"showMoreFormats": "Показать больше вариантов форматов",
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
"subscriptionDefaults": {
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.",
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
},
"system": "Системная",
"theme": "Тема",
"themeDescription": "Выберите светлую, тёмную или системную тему для VidBee",
"title": "Настройки",
"tray": {
"quit": "Выход",
"showHome": "Показать главную"
},
"video": "Настройки видео"
},
"subscriptions": {
"title": "Подписки",
"subtitle": "{{count}} подписка{{count, plural, one {} other {}}}",
"description": "Автоматически отслеживайте RSS-каналы и добавляйте новые загрузки в очередь без ручной работы.",
"defaults": {
"title": "Настройки автоматизации по умолчанию",
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
"downloadDirectory": "Директория загрузки",
"filenameTemplate": "Шаблон имени файла (только файл)",
"checkInterval": "Интервал проверки (часы)",
"onlyLatest": "Загружать только последнее видео",
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
},
"add": {
"title": "Добавить RSS",
"description": "Вставьте ссылку на RSS-канал. VidBee автоматически определит канал."
},
"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": "Не удалось разрешить URL RSS-канала."
},
"detectedFeed": "Обнаружен канал {{platform}} -> {{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": "Нет URL RSS-канала? Используйте RSSHub для создания RSS-каналов для YouTube, Twitter и тысяч других платформ."
}
},
"sites": {
"homeInlineDescription": "Поддерживает {{sites}} и другие.",
"moreDescription": "Полный список yt-dlp постоянно обновляется сообществом.",
"moreTitle": "Нужен другой сайт?",
"openFullList": "Открыть полный список поддерживаемых сайтов",
"pageDescription": "VidBee использует yt-dlp под капотом для доступа к сотням источников.",
"pageIntro": "Вот основные сервисы, с которых люди чаще всего загружают.",
"pageTitle": "Поддерживаемые сайты",
"popular": {
"bandcamp": {
"description": "Альбомы независимых артистов и релизы сообщества.",
"label": "Bandcamp"
},
"dailymotion": {
"description": "Глобальные новости, спорт и развлекательные клипы.",
"label": "Dailymotion"
},
"facebook": {
"description": "Видео из ленты, Watch и Reels с публичных страниц.",
"label": "Facebook"
},
"instagram": {
"description": "Контент из ленты, Stories, Reels и Highlights.",
"label": "Instagram"
},
"kick": {
"description": "Прямые трансляции и повторы создателей на платформе Kick.",
"label": "Kick"
},
"linkedin": {
"description": "Профессиональные выступления, вебинары и обучающие видео.",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ-миксы, радиошоу и длинные аудиоформаты.",
"label": "Mixcloud"
},
"niconico": {
"description": "Японская анимация, музыка и архив прямых трансляций.",
"label": "Niconico"
},
"pinterest": {
"description": "Идеи для пинов, обучающие ролики и видео-вдохновения для образа жизни.",
"label": "Pinterest"
},
"reddit": {
"description": "Встроенные клипы и размещённые видео из сообществ.",
"label": "Reddit"
},
"soundcloud": {
"description": "Музыкальные треки, плейлисты и DJ-сеты.",
"label": "SoundCloud"
},
"tiktok": {
"description": "Короткие мобильные видео, эффекты и прямые трансляции.",
"label": "TikTok"
},
"tumblr": {
"description": "Креативные короткие медиа и фанатские правки.",
"label": "Tumblr"
},
"twitch": {
"description": "Игровые, музыкальные и IRL прямые трансляции и VOD.",
"label": "Twitch"
},
"twitter": {
"description": "Посты из ленты, записи Spaces и трансляции.",
"label": "X (Twitter)"
},
"vimeo": {
"description": "Высококачественный хостинг видео для создателей и бизнеса.",
"label": "Vimeo"
},
"youtube": {
"description": "Длинные видео и прямые трансляции от создателей по всему миру.",
"label": "YouTube"
},
"youtubemusic": {
"description": "Официальные музыкальные видео, альбомы и живые выступления.",
"label": "YouTube Music"
}
},
"popularSection": "Основные платформы",
"viewAll": "Просмотреть все поддерживаемые сайты"
}
}

View File

@@ -0,0 +1,585 @@
{
"about": {
"actions": {
"checkUpdates": "檢查更新",
"download": "下載",
"email": "電子郵件",
"feedback": "意見回饋",
"goToDownload": "前往下載頁面",
"openRepo": "開啟 GitHub 儲存庫",
"view": "檢視",
"visit": "造訪"
},
"appName": "VidBee",
"autoUpdateDescription": "在背景自動下載並安裝新版本。",
"autoUpdateTitle": "自動更新",
"betaProgramDescription": "搶先獲得預覽版和即將發布的新功能。",
"betaProgramTitle": "預覽通道",
"description": "VidBee 是一個基於 Node.js 和 Electron 構建的自由開源應用,使用 yt-dlp 來完成下載。",
"downloadingUpdate": "正在下載更新",
"followAuthorActions": {
"follow": "關注 @nexmoex"
},
"followAuthorDescription": "獲取 VidBee 的最新消息和更新。",
"followAuthorSupport": "在 X (Twitter) 上關注開發者,獲取 VidBee 的最新更新和消息。",
"followAuthorTitle": "關注開發者",
"here": "此處",
"homepage": "首頁",
"latestVersionBadge": "最新版本v{{version}}",
"latestVersionStatus": {
"available": "有新版本可用",
"error": "無法取得最新版本",
"uptodate": "您已是最新版本"
},
"notifications": {
"checkingUpdates": "正在搜尋更新...",
"downloadError": "下載更新失敗",
"downloadStarted": "開始下載...",
"downloadUpdate": "下載並安裝更新 {{version}}",
"manualDownloadAction": "立即下載",
"noUpdatesAvailable": "您正在使用最新版本",
"restartNowAction": "立即重新啟動",
"restartToUpdate": "立即重新啟動以安裝更新?",
"unknownErrorFallback": "未知錯誤",
"updateAvailable": "發現新版本:{{version}}",
"updateAvailableMessage": "新版本 {{version}} 已推出。請從官方網站下載。",
"updateDownloaded": "更新已下載,重新啟動以安裝",
"updateDownloadedVersion": "下載更新 {{version}},重新啟動安裝",
"updateError": "檢查更新失敗:{{error}}"
},
"preferencesDescription": "無需離開此頁即可調整更新設定。",
"preferencesTitle": "快速切換",
"resources": {
"changelog": "發行說明",
"changelogDescription": "了解每個版本的變更內容。",
"contact": "電子郵件支援",
"contactDescription": "直接聯繫我們以獲取協助或開展合作。",
"documentation": "說明中心",
"documentationDescription": "指南、常見問題和常見流程。",
"feedback": "意見回饋與問題",
"feedbackDescription": "在 GitHub 上分享想法或回報問題。",
"license": "授權條款",
"licenseDescription": "查閱開源授權條款。",
"website": "官方網站",
"websiteDescription": "產品亮點、路線圖與社群動態。"
},
"resourcesDescription": "了解 VidBee 並保持關注的實用連結。",
"resourcesTitle": "資源",
"shareActions": {
"copy": "複製連結",
"facebook": "在 Facebook 上分享",
"twitter": "在 X (Twitter) 上分享"
},
"shareDescription": "一鍵與您的社群分享 VidBee。",
"shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。",
"shareTitle": "廣為宣傳",
"sourceCode": "原始碼已開放",
"title": "關於",
"version": "版本",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "下載完成後關閉應用程式",
"currentLocation": "目前下載位置 - ",
"downloadLocation": "下載位置",
"downloadSubs": "若有字幕則下載",
"end": "結束",
"endHint": "如果留空,將下載到結尾",
"endPlaceholder": "10:00",
"selectLocation": "選擇下載位置",
"start": "開始",
"startHint": "如果留空,將從開頭開始",
"startPlaceholder": "00:00",
"subtitles": "字幕",
"timeRange": "下載指定時間範圍",
"title": "進階選項"
},
"app": {
"description": "從數百個網站下載影片和音訊",
"title": "VidBee"
},
"audioExtract": {
"bad": "較差",
"best": "最佳",
"extract": "擷取",
"good": "良好",
"normal": "標準",
"selectFormat": "選擇格式",
"selectQuality": "選擇品質",
"title": "擷取音訊",
"worst": "最差"
},
"download": {
"active": "進行中",
"all": "全部",
"audio": "音訊",
"back": "返回",
"cancel": "取消",
"cancelled": "已取消",
"clearCompleted": "清除已完成",
"clearDownloads": "清除下載",
"completed": "已完成",
"downloadAudio": "下載音訊",
"downloadBtn": "下載",
"downloadPending": "待處理",
"downloadQueue": "下載佇列",
"downloadVideo": "下載影片",
"downloading": "正在下載...",
"enterUrl": "輸入影片連結",
"enterUrlDescription": "貼上或輸入一個影片連結。",
"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": "未找到項目",
"oneClickDownload": "一鍵下載",
"oneClickDownloadDescription": "使用預設設定直接下載,無需確認",
"oneClickDownloadEnabled": "已啟用一鍵下載。下載將直接以默認設置開始。",
"oneClickDownloadNow": "立即下載",
"oneClickDownloadStarted": "已使用預設設定開始下載",
"paste": "貼上",
"pastePlaylistUrl": "點擊從剪貼簿貼上播放清單連結 [Ctrl + V]",
"pasteUrl": "點擊貼上影片連結或 ID [Ctrl + V]",
"preparing": "正在準備...",
"processing": "處理中",
"progress": "進度",
"selectAudioFormat": "選擇音訊格式",
"selectFormat": "選擇格式",
"selectVideoFormat": "選擇影片格式",
"showDetails": "顯示詳情",
"singleVideo": "單個影片",
"speed": "速度",
"title": "標題",
"total": "總計",
"unknownQuality": "未知品質",
"unknownSize": "未知大小",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "影片",
"videoInfo": "影片資訊",
"videoInfoUpdated": "影片資訊已更新"
},
"errors": {
"clickToCopy": "點擊複製詳情",
"clipboardEmpty": "剪貼簿為空",
"downloadFailed": "下載失敗",
"downloadNecessaryFilesFailed": "必要檔案下載失敗。請檢查網路後再試",
"emptyUrl": "請輸入連結",
"errorDetails": "錯誤詳情",
"fetchInfoFailed": "取得影片資訊失敗",
"networkError": "發生錯誤。請檢查網路並確認連結正確",
"pasteFromClipboard": "從剪貼簿貼上失敗"
},
"history": {
"clearCancelled": "清除已取消",
"clearCompleted": "清除已完成",
"clearErrors": "清除錯誤",
"copyToClipboard": "複製到剪貼簿",
"copyUrl": "複製連結",
"date": "日期",
"description": "檢視並管理下載歷史",
"duration": "時長",
"fileSize": "檔案大小",
"filters": {
"all": "全部",
"cancelled": "已取消",
"completed": "已完成",
"errors": "錯誤"
},
"noHistory": "暫無下載歷史",
"noHistoryDescription": "完成的下載會顯示在這裡",
"openDownloadFolder": "開啟下載資料夾",
"openFile": "開啟檔案",
"openFileLocation": "開啟檔案位置",
"openFolder": "開啟資料夾",
"openInBrowser": "點擊在瀏覽器中開啟",
"removeItem": "移除項目",
"stats": {
"cancelled": "已取消",
"completed": "已完成",
"errors": "錯誤",
"total": "總計"
},
"status": {
"cancelled": "已取消",
"completed": "已完成",
"error": "錯誤"
},
"title": "下載歷史"
},
"menu": {
"about": "關於",
"download": "下載",
"playlist": "下載播放清單",
"preferences": "偏好設定",
"rss": "RSS",
"subscriptions": "訂閱",
"supportedSites": "支援的網站",
"theme": "主題:"
},
"notifications": {
"copyFailed": "複製到剪貼簿失敗",
"downloadCompleted": "下載完成",
"downloadFailed": "下載失敗",
"downloadStarted": "下載已開始",
"itemRemoved": "項目已移除",
"openFileFailed": "開啟檔案失敗",
"openFolderFailed": "開啟資料夾失敗",
"removeFailed": "移除項目失敗",
"settingsSaved": "設定已儲存",
"urlCopied": "連結已複製到剪貼簿",
"videoCopied": "影片已複製到剪貼簿"
},
"playlist": {
"badgeLabel": "播放列表",
"clearPreview": "清晰預覽",
"comingSoon": "播放清單下載功能即將推出!",
"completed": "播放清單已下載",
"description": "下載 YouTube 播放清單或頻道中的全部影片",
"downloadFailed": "啟動播放清單下載失敗",
"downloadPlaylist": "下載播放清單",
"downloadStarted": "已開始下載播放清單中的 {{count}} 個影片",
"downloadType": "下載類型",
"downloading": "正在下載播放清單:",
"endIndex": "結束",
"enterPlaylistUrl": "輸入播放清單連結",
"fetchFailed": "取得播放清單資訊失敗",
"filenameFormat": "播放清單檔案名稱格式",
"folderFormat": "播放清單資料夾命名格式",
"foundVideos": "在播放清單中找到 {{count}} 個影片",
"groupActive": "{{count}} 個活躍",
"groupErrors": "{{count}} 失敗",
"groupSummary": "{{已完成}} / {{總計}}已完成",
"linkLabel": "播放清單連結",
"noEntries": "在此播放列表中找不到視頻",
"noEntriesInRange": "所選範圍內沒有視頻",
"noRangeSelected": "沒有結束設置 - 已選擇完整播放列表",
"playlistUrlDescription": "批量下載播放清單中的所有影片",
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
"previewButton": "預覽播放列表",
"previewFailed": "預覽播放列表失敗",
"previewRequired": "下載前預覽播放列表。",
"previewSummary": "下載前預覽播放列表項目。",
"range": "範圍(可選)",
"resetToDefault": "恢復預設",
"selectedRange": "範圍:{{開始}}-{{結束}}",
"showingCount": "顯示 {{count}} 個視頻",
"startIndex": "開始1",
"title": "下載播放清單",
"totalVideos": "視頻總數:{{count}}",
"untitled": "無標題播放列表"
},
"settings": {
"aboutTab": "關於",
"advanced": "進階",
"app": "應用程式設定",
"audio": "音訊偏好",
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"clearConfigFile": "清除",
"clearCookiesFile": "清除",
"configFile": "使用設定檔",
"configFileDescription": "yt-dlp 的自訂設定檔",
"cookiesFile": "餅乾文件",
"cookiesFileDescription": "要加載以進行身份​​驗證的 Netscape 格式的 cookie 文件",
"cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。",
"cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答",
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ並在需要時在此處選擇它。",
"cookiesHelpTitle": "使用cookie",
"dark": "深色",
"description": "設定下載偏好和應用程式設定",
"directorySelectError": "選擇目錄失敗",
"downloadPath": "下載位置",
"downloadPathDescription": "選擇儲存下載檔案的位置",
"enableAnalytics": "幫助改進 VidBee",
"enableAnalyticsDescription": "共享匿名使用數據,幫助我們了解應用程序的使用情況並確定改進的優先順序。",
"fileSelectError": "選擇檔案失敗",
"general": "一般",
"hideDockIcon": "隱藏 Dock 圖標",
"hideDockIconDescription": "從 macOS Dock 中刪除 VidBee。使用菜單欄或託盤圖標重新打開應用程序。",
"language": "語言",
"launchAtLogin": "啟動時啟動",
"launchAtLoginDescription": "登錄計算機後自動打開 VidBee。",
"launchAtLoginUnsupported": "自動啟動僅適用於 macOS 和 Windows。",
"light": "淺色",
"maxConcurrentDownloads": "最大活動下載數",
"maxConcurrentDownloadsDescription": "最大同時下載數量",
"none": "無",
"oneClickDownload": "一鍵下載",
"oneClickDownloadDescription": "啟用使用預設設定的一鍵下載",
"oneClickDownloadType": "預設下載類型",
"oneClickDownloadTypeDescription": "選擇一鍵下載的預設下載類型。品質使用下面的預設。",
"oneClickQuality": "首選品質",
"oneClickQualityDescription": "選擇用於一鍵下載的品質預設",
"oneClickQualityOptions": {
"auto": "自動",
"bad": "較差",
"best": "最佳",
"good": "良好",
"normal": "標準",
"worst": "最差"
},
"openLinkError": "無法打開鏈接",
"proxy": "代理伺服器",
"proxyDescription": "網路請求的代理伺服器",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "選擇設定檔",
"selectPath": "選擇",
"showMoreFormats": "顯示更多格式選項",
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
"subscriptionDefaults": {
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
"intervalDescription": "VidBee 檢查每個訂閱源的頻率1-24 小時)。"
},
"system": "系統",
"theme": "主題",
"themeDescription": "為 VidBee 選擇淺色、深色或系統主題",
"title": "設定",
"tray": {
"quit": "結束",
"showHome": "顯示首頁"
},
"video": "影片偏好"
},
"sites": {
"homeInlineDescription": "支援 {{sites}} 等更多網站。",
"moreDescription": "完整的 yt-dlp 清單由社群持續更新。",
"moreTitle": "需要其他網站?",
"openFullList": "開啟全部支援網站清單",
"pageDescription": "VidBee 使用 yt-dlp 覆蓋數百個資源。",
"pageIntro": "以下是大家最常下載的主流服務。",
"pageTitle": "支援的網站",
"popular": {
"bandcamp": {
"description": "獨立藝術家專輯和社群發布。",
"label": "Bandcamp"
},
"dailymotion": {
"description": "全球新聞、體育和娛樂片段。",
"label": "Dailymotion"
},
"facebook": {
"description": "來自公開頁面的動態、觀看和 Reels 影片。",
"label": "Facebook"
},
"instagram": {
"description": "動態、故事、Reels 和精選內容。",
"label": "Instagram"
},
"kick": {
"description": "Kick 平台上的創作者直播和回放。",
"label": "Kick"
},
"linkedin": {
"description": "專業演講、網路研討會和學習影片。",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ 混音、廣播節目和長音訊。",
"label": "Mixcloud"
},
"niconico": {
"description": "日本動畫、音樂和直播檔案。",
"label": "Niconico"
},
"pinterest": {
"description": "創意圖釘、教學 Reels 和生活方式靈感影片。",
"label": "Pinterest"
},
"reddit": {
"description": "來自社群的嵌入片段和託管影片。",
"label": "Reddit"
},
"soundcloud": {
"description": "音樂曲目、播放清單和 DJ 套裝。",
"label": "SoundCloud"
},
"tiktok": {
"description": "短影片、特效和直播。",
"label": "TikTok"
},
"tumblr": {
"description": "創意短影片和粉絲編輯。",
"label": "Tumblr"
},
"twitch": {
"description": "遊戲、音樂和 IRL 直播和 VOD。",
"label": "Twitch"
},
"twitter": {
"description": "時間軸貼文、Spaces 錄音和廣播。",
"label": "X (Twitter)"
},
"vimeo": {
"description": "高品質創作者和商業影片託管。",
"label": "Vimeo"
},
"youtube": {
"description": "來自全球創作者的長影片和直播。",
"label": "YouTube"
},
"youtubemusic": {
"description": "官方音樂影片、專輯和現場表演。",
"label": "YouTube Music"
}
},
"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": "訂閱"
}
}

View File

@@ -2,8 +2,10 @@
"about": {
"actions": {
"checkUpdates": "检查更新",
"download": "下载",
"email": "邮件",
"feedback": "反馈",
"goToDownload": "前往下载页面",
"openRepo": "打开 GitHub 仓库",
"view": "查看",
"visit": "访问"
@@ -14,18 +16,36 @@
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
"betaProgramTitle": "预览通道",
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
"downloadingUpdate": "正在下载更新",
"followAuthorActions": {
"follow": "关注 @nexmoex"
},
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
"followAuthorTitle": "关注开发者",
"here": "此处",
"homepage": "主页",
"latestVersionBadge": "最新版本v{{version}}",
"latestVersionStatus": {
"available": "有新版本可用",
"error": "无法获取最新版本",
"uptodate": "您已是最新版本"
},
"notifications": {
"checkingUpdates": "正在查找更新...",
"updateAvailable": "发现新版本: {{version}}",
"noUpdatesAvailable": "您正在使用最新版本",
"updateError": "检查更新失败: {{error}}",
"downloadUpdate": "下载并安装更新 {{version}}",
"downloadStarted": "开始下载...",
"downloadError": "下载更新失败",
"downloadStarted": "开始下载...",
"downloadUpdate": "下载并安装更新 {{version}}",
"manualDownloadAction": "立即下载",
"noUpdatesAvailable": "您正在使用最新版本",
"restartNowAction": "立即重新启动",
"restartToUpdate": "立即重启以安装更新?",
"unknownErrorFallback": "未知错误",
"updateAvailable": "发现新版本: {{version}}",
"updateAvailableMessage": "新版本 {{version}} 已推出。\n请从官方网站下载。",
"updateDownloaded": "更新已下载,重启以安装",
"restartToUpdate": "立即重启以安装更新?"
"updateDownloadedVersion": "下载更新 {{version}},重新启动安装",
"updateError": "检查更新失败: {{error}}"
},
"preferencesDescription": "无需离开此页即可调整更新设置。",
"preferencesTitle": "快速切换",
@@ -45,8 +65,15 @@
},
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"shareActions": {
"copy": "复制链接",
"facebook": "在脸书上分享",
"twitter": "在 X 上分享(推特)"
},
"shareDescription": "一键与您的社区分享 VidBee。",
"shareSupport": "向您的朋友推荐VidBee以支持我们的成长和更新。",
"shareTitle": "传播这个词",
"sourceCode": "源代码已开放",
"tagline": "面向每位创作者的 AI 友好下载助手",
"title": "关于",
"version": "版本",
"versionLabel": "v{{version}}"
@@ -103,11 +130,39 @@
"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": "无音频",
@@ -115,6 +170,7 @@
"noItems": "未找到项目",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "使用默认设置直接下载,无需确认",
"oneClickDownloadEnabled": "已启用一键下载。\n下载将直接以默认设置开始。",
"oneClickDownloadNow": "立即下载",
"oneClickDownloadStarted": "已使用默认设置开始下载",
"paste": "粘贴",
@@ -126,6 +182,7 @@
"selectAudioFormat": "选择音频格式",
"selectFormat": "选择格式",
"selectVideoFormat": "选择视频格式",
"showDetails": "显示详情",
"singleVideo": "单个视频",
"speed": "速度",
"title": "标题",
@@ -152,8 +209,8 @@
"clearCancelled": "清除已取消",
"clearCompleted": "清除已完成",
"clearErrors": "清除错误",
"copyToClipboard": "复制到剪贴板",
"copyUrl": "复制链接",
"openInBrowser": "点击在浏览器中打开",
"date": "日期",
"description": "查看并管理下载历史",
"duration": "时长",
@@ -166,11 +223,11 @@
},
"noHistory": "暂无下载历史",
"noHistoryDescription": "完成的下载会显示在这里",
"openDownloadFolder": "打开下载文件夹",
"openFile": "打开文件",
"openFileLocation": "打开文件位置",
"openFolder": "打开文件夹",
"openDownloadFolder": "打开下载文件夹",
"outputPath": "输出路径",
"openInBrowser": "点击在浏览器中打开",
"removeItem": "移除项目",
"stats": {
"cancelled": "已取消",
@@ -190,6 +247,8 @@
"download": "下载",
"playlist": "下载播放列表",
"preferences": "偏好设置",
"rss": "RSS",
"subscriptions": "订阅",
"supportedSites": "支持的网站",
"theme": "主题:"
},
@@ -203,9 +262,12 @@
"openFolderFailed": "打开文件夹失败",
"removeFailed": "移除项目失败",
"settingsSaved": "设置已保存",
"urlCopied": "链接已复制到剪贴板"
"urlCopied": "链接已复制到剪贴板",
"videoCopied": "视频已复制到剪贴板"
},
"playlist": {
"badgeLabel": "播放列表",
"clearPreview": "清晰预览",
"comingSoon": "播放列表下载功能即将推出!",
"completed": "播放列表已下载",
"description": "下载 YouTube 播放列表或频道中的全部视频",
@@ -220,12 +282,27 @@
"filenameFormat": "播放列表文件名格式",
"folderFormat": "播放列表文件夹命名格式",
"foundVideos": "在播放列表中找到 {{count}} 个视频",
"groupActive": "{{count}} 个活跃",
"groupErrors": "{{count}} 失败",
"groupSummary": "{{completed}} / {{total}}已完成",
"linkLabel": "播放列表链接",
"noEntries": "在此播放列表中找不到视频",
"noEntriesInRange": "所选范围内没有视频",
"noRangeSelected": "没有结束设置 - 已选择完整播放列表",
"playlistUrlDescription": "批量下载播放列表中的所有视频",
"positionLabel": "第 {{index}} 项,共 {{total}} 项",
"previewButton": "预览播放列表",
"previewFailed": "预览播放列表失败",
"previewRequired": "下载前预览播放列表。",
"previewSummary": "下载前预览播放列表项目。",
"range": "范围(可选)",
"resetToDefault": "恢复默认",
"selectedRange": "范围:{{start}}-{{end}}",
"showingCount": "显示 {{count}} 个视频",
"startIndex": "开始1",
"title": "下载播放列表"
"title": "下载播放列表",
"totalVideos": "视频总数:{{count}}",
"untitled": "无标题播放列表"
},
"settings": {
"aboutTab": "关于",
@@ -233,34 +310,72 @@
"app": "应用设置",
"audio": "音频偏好",
"browserForCookies": "选择用于读取 Cookie 的浏览器",
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
"browserOptions": {
"brave": "Brave",
"chrome": "Chrome",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
},
"clearConfigFile": "清除",
"clearCookiesFile": "清除",
"configFile": "使用配置文件",
"configFileDescription": "yt-dlp 的自定义配置文件",
"cookiesFile": "饼干文件",
"cookiesFileDescription": "要加载以进行身份​​验证的 Netscape 格式的 cookie 文件",
"cookiesHelpBrowser": "选择上面的浏览器以自动重用其登录会话。",
"cookiesHelpFaq": "打开 yt-dlp cookies 常见问题解答",
"cookiesHelpFile": "导出 Netscape cookies 文件(请参阅 yt-dlp FAQ并在需要时在此处选择它。",
"cookiesHelpTitle": "使用cookie",
"dark": "深色",
"description": "配置下载偏好和应用设置",
"directorySelectError": "选择目录失败",
"downloadPath": "下载位置",
"downloadPathDescription": "选择保存下载文件的位置",
"enableAnalytics": "帮助改进 VidBee",
"enableAnalyticsDescription": "共享匿名使用数据,帮助我们了解应用程序的使用情况并确定改进的优先顺序。",
"fileSelectError": "选择文件失败",
"general": "通用",
"hideDockIcon": "隐藏 Dock 图标",
"hideDockIconDescription": "从 macOS Dock 中删除 VidBee。\n使用菜单栏或托盘图标重新打开应用程序。",
"language": "语言",
"languageOptions": {
"chinese": "简体中文",
"english": "英语"
},
"launchAtLogin": "启动时启动",
"launchAtLoginDescription": "登录计算机后自动打开 VidBee。",
"launchAtLoginUnsupported": "自动启动仅适用于 macOS 和 Windows。",
"light": "浅色",
"maxConcurrentDownloads": "最大活动下载数",
"maxConcurrentDownloadsDescription": "最大同时下载数量",
"none": "无",
"oneClickAudioForVideo": "视频默认音频",
"oneClickAudioFormat": "默认音频格式",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "启用使用默认设置的一键下载",
"oneClickDownloadType": "默认下载类型",
"oneClickVideoFormat": "默认视频格式",
"preferredAudioQuality": "首选音频质量",
"preferredVideoCodec": "首选视频编码",
"preferredVideoQuality": "首选视频质量",
"oneClickDownloadTypeDescription": "选择一键下载的默认下载类型。质量使用下面的预设。",
"oneClickQuality": "首选质量",
"oneClickQualityDescription": "选择用于一键下载的质量预设",
"oneClickQualityOptions": {
"auto": "自动",
"bad": "较差",
"best": "最佳",
"good": "良好",
"normal": "标准",
"worst": "最差"
},
"openLinkError": "无法打开链接",
"proxy": "代理",
"proxyDescription": "网络请求的代理服务器",
"proxyPlaceholder": "http://proxy:port",
"selectConfigFile": "选择配置文件",
"selectPath": "选择",
"showMoreFormats": "显示更多格式选项",
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
"subscriptionDefaults": {
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
"intervalDescription": "VidBee 检查每个订阅源的频率1-24 小时)。"
},
"system": "系统",
"theme": "主题",
"themeDescription": "为 VidBee 选择浅色、深色或系统主题",
"title": "设置",
"tray": {
"quit": "退出",
@@ -276,7 +391,195 @@
"pageDescription": "VidBee 使用 yt-dlp 覆盖数百个资源。",
"pageIntro": "以下是大家最常下载的主流服务。",
"pageTitle": "支持的网站",
"popular": {
"bandcamp": {
"description": "独立艺术家专辑和社区发布。",
"label": "Bandcamp"
},
"dailymotion": {
"description": "全球新闻、体育和娱乐片段。",
"label": "Dailymotion"
},
"facebook": {
"description": "来自公共页面的动态、观看和 Reels 视频。",
"label": "Facebook"
},
"instagram": {
"description": "动态、故事、Reels 和精选内容。",
"label": "Instagram"
},
"kick": {
"description": "Kick 平台上的创作者直播和回放。",
"label": "Kick"
},
"linkedin": {
"description": "专业演讲、网络研讨会和学习视频。",
"label": "LinkedIn"
},
"mixcloud": {
"description": "DJ 混音、广播节目和长音频。",
"label": "Mixcloud"
},
"niconico": {
"description": "日本动画、音乐和直播档案。",
"label": "Niconico"
},
"pinterest": {
"description": "创意图钉、教程 Reels 和生活方式灵感视频。",
"label": "Pinterest"
},
"reddit": {
"description": "来自社区的嵌入片段和托管视频。",
"label": "Reddit"
},
"soundcloud": {
"description": "音乐曲目、播放列表和 DJ 套装。",
"label": "SoundCloud"
},
"tiktok": {
"description": "短视频、特效和直播。",
"label": "TikTok"
},
"tumblr": {
"description": "创意短视频和粉丝编辑。",
"label": "Tumblr"
},
"twitch": {
"description": "游戏、音乐和 IRL 直播和 VOD。",
"label": "Twitch"
},
"twitter": {
"description": "时间线帖子、Spaces 录音和广播。",
"label": "X (Twitter)"
},
"vimeo": {
"description": "高质量创作者和商业视频托管。",
"label": "Vimeo"
},
"youtube": {
"description": "来自全球创作者的长视频和直播。",
"label": "YouTube"
},
"youtubemusic": {
"description": "官方音乐视频、专辑和现场表演。",
"label": "YouTube Music"
}
},
"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": "订阅"
}
}

View File

@@ -1,5 +1,6 @@
import './assets/main.css'
import './assets/global.css'
import 'flag-icons/css/flag-icons.min.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

View File

@@ -7,10 +7,12 @@ import {
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'
import type { LucideIcon } from 'lucide-react'
import {
Download,
Facebook,
FileText,
Github,
@@ -24,7 +26,7 @@ import {
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { ipcEvents, ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
interface AboutResource {
@@ -36,12 +38,20 @@ interface AboutResource {
onClick?: () => void
}
type LatestVersionState =
| { status: 'available'; version: string }
| { status: 'uptodate'; version: string }
| { status: 'error'; error?: string }
| null
export function About() {
const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
const shareTargetUrl = 'https://vidbee.org'
useEffect(() => {
let isActive = true
@@ -64,12 +74,91 @@ export function About() {
}
}, [])
// Listen for update events only in About page
useEffect(() => {
if (!window?.api) {
return
}
const handleUpdateAvailable = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
const versionLabel = info.version ?? ''
// Update will be downloaded automatically because autoDownload is enabled in main process
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
setLatestVersionState({
status: 'available',
version: versionLabel
})
// Reset download progress when new update is available
setUpdateDownloadProgress(0)
}
const handleUpdateDownloadProgress = (rawProgress: unknown) => {
const progress = (rawProgress ?? {}) as { percent?: number }
if (typeof progress?.percent === 'number') {
setUpdateDownloadProgress(progress.percent)
}
}
const handleUpdateDownloaded = () => {
// Clear progress when download is complete
setUpdateDownloadProgress(null)
}
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
}
}, [t])
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
// If auto-update is enabled, check for updates immediately
if (key === 'autoUpdate' && value === true) {
try {
toast.info(t('about.notifications.checkingUpdates'))
const result = await ipcServices.update.checkForUpdates()
if (result.available) {
// The update will be downloaded automatically because autoDownload is enabled
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
setLatestVersionState({
status: 'available',
version: result.version ?? ''
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
status: 'error',
error: result.error
})
} else {
toast.success(t('about.notifications.noUpdatesAvailable'))
setLatestVersionState({
status: 'uptodate',
version: result.version ?? appVersion
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
}
}
}
const handleGoToDownload = () => {
openShareUrl('https://vidbee.org/download/')
}
const handleCheckForUpdates = async () => {
@@ -79,20 +168,35 @@ export function About() {
if (result.available) {
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
setLatestVersionState({
status: 'available',
version: result.version ?? ''
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
status: 'error',
error: result.error
})
} else {
toast.success(t('about.notifications.noUpdatesAvailable'))
setLatestVersionState({
status: 'uptodate',
version: result.version ?? appVersion
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
setLatestVersionState({
status: 'error'
})
}
}
const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.tagline'))
const encodedText = encodeURIComponent(t('about.description'))
return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
@@ -126,8 +230,30 @@ export function About() {
}
}
const latestVersionBadgeText =
latestVersionState && latestVersionState.status !== 'error' && latestVersionState.version
? t('about.latestVersionBadge', { version: latestVersionState.version })
: null
const latestVersionStatusKey = latestVersionState
? `about.latestVersionStatus.${latestVersionState.status}`
: null
const latestVersionStatusClass =
latestVersionState?.status === 'available'
? 'text-primary'
: latestVersionState?.status === 'error'
? 'text-destructive'
: 'text-muted-foreground'
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
const aboutResources = useMemo<AboutResource[]>(
() => [
{
icon: LinkIcon,
label: t('about.resources.website'),
description: t('about.resources.websiteDescription'),
actionLabel: t('about.actions.visit'),
href: 'https://vidbee.org/'
},
{
icon: FileText,
label: t('about.resources.changelog'),
@@ -163,11 +289,6 @@ export function About() {
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
<p className="text-muted-foreground">{t('about.description')}</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -176,11 +297,38 @@ export function About() {
<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.tagline')}</p>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
<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" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -194,6 +342,12 @@ export function About() {
<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')}
@@ -218,21 +372,44 @@ export function About() {
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyShareLink}
className="gap-2"
>
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">
{t('about.followAuthorSupport')}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openShareUrl('https://x.com/nexmoex')}
className="gap-2"
>
<Twitter className="h-4 w-4" />
{t('about.followAuthorActions.follow')}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -15,15 +15,16 @@ import {
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Tabs, TabsContent } from '@renderer/components/ui/tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset } from '@shared/types'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
import { VideoInfoCard } from '../components/video/VideoInfoCard'
import { ipcEvents, ipcServices } from '../lib/ipc'
import {
@@ -41,7 +42,6 @@ import {
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -50,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -71,22 +70,24 @@ const dedupe = (candidates: Array<string | undefined>): string[] => {
}
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
settings.oneClickQuality ?? 'auto'
settings.oneClickQuality ?? 'best'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio', 'worst']
return ['worstaudio']
}
const abrLimit = qualityPresetToAudioAbr[preset]
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best'])
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
return 'worstvideo+worstaudio/worst'
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
}
const maxHeight = qualityPresetToVideoHeight[preset]
@@ -109,7 +110,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
combinations.push(video)
}
} else {
combinations.push('best')
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
combinations.push('bestvideo+bestaudio')
}
return dedupe(combinations).join('/')
@@ -122,9 +124,10 @@ const buildAudioFormatPreference = (settings: AppSettings): string => {
interface HomeProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
}
export function Home({ onOpenSupportedSites }: HomeProps) {
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
@@ -139,19 +142,51 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
const [url, setUrl] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
const inlinePreviewSites = popularSites
.slice(0, 3)
.map((site) => site.label)
.map((site) => t(`sites.popular.${site.id}.label`))
.join(', ')
// Playlist states
const playlistUrlId = useId()
const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('')
const [playlistLoading, setPlaylistLoading] = useState(false)
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const computePlaylistRange = useCallback(
(info: PlaylistInfo) => {
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
const endValue =
rawEnd !== undefined
? info.entryCount > 0
? Math.min(rawEnd, info.entryCount)
: rawEnd
: undefined
return { start, end: endValue }
},
[startIndex, endIndex]
)
const selectedPlaylistEntries = useMemo(() => {
if (!playlistInfo) {
return []
}
const range = computePlaylistRange(playlistInfo)
const previewEnd = range.end ?? playlistInfo.entryCount
return playlistInfo.entries.filter(
(entry) => entry.index >= range.start && entry.index <= previewEnd
)
}, [playlistInfo, computePlaylistRange])
const syncHistoryItem = useCallback(
async (id: string) => {
@@ -366,70 +401,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
// Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => {
if (playlistBusy) return
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setPlaylistUrl(text.trim())
const trimmed = text.trim()
setPlaylistUrl(trimmed)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
}, [playlistBusy, t])
const handleDownloadPlaylist = useCallback(async () => {
const handleClearPlaylistPreview = useCallback(() => {
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}, [])
const handlePreviewPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
setPlaylistLoading(true)
setPlaylistPreviewError(null)
setPlaylistPreviewLoading(true)
try {
// Get playlist info first to show user what will be downloaded
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
const trimmedUrl = playlistUrl.trim()
const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
toast.success(t('playlist.foundVideos', { count: info.entryCount }))
} catch (error) {
console.error('Failed to fetch playlist info:', error)
const message =
error instanceof Error && error.message ? error.message : t('playlist.previewFailed')
setPlaylistPreviewError(message)
setPlaylistInfo(null)
toast.error(t('playlist.previewFailed'))
} finally {
setPlaylistPreviewLoading(false)
}
}, [playlistUrl, t])
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount }))
const handleDownloadPlaylist = useCallback(async () => {
const trimmedUrl = playlistUrl.trim()
if (!trimmedUrl) {
toast.error(t('errors.emptyUrl'))
return
}
if (!playlistInfo) {
toast.error(t('playlist.previewRequired'))
return
}
setPlaylistPreviewError(null)
setPlaylistDownloadLoading(true)
try {
const info = playlistInfo
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
const range = computePlaylistRange(info)
const previewEnd = range.end ?? info.entryCount
if (previewEnd < range.start || previewEnd === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
// Build format preference based on settings
const format =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
// Start playlist download
const downloadIds = await ipcServices.download.startPlaylistDownload({
url: playlistUrl.trim(),
const result = await ipcServices.download.startPlaylistDownload({
url: trimmedUrl,
type: downloadType,
format,
startIndex: parseInt(startIndex, 10) || 1,
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
startIndex: range.start,
endIndex: range.end
})
// Add all downloads to the renderer state
for (const id of downloadIds) {
if (result.totalCount === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
const baseCreatedAt = Date.now()
result.entries.forEach((entry, index) => {
const downloadItem = {
id,
url: playlistUrl.trim(),
title: t('download.fetchingVideoInfo'),
id: entry.downloadId,
url: entry.url,
title: entry.title || t('download.fetchingVideoInfo'),
type: downloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
createdAt: baseCreatedAt + index,
playlistId: result.groupId,
playlistTitle: result.playlistTitle,
playlistIndex: entry.index,
playlistSize: result.totalCount
}
addDownload(downloadItem)
}
})
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
setPlaylistUrl('') // Clear the URL after starting download
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
} catch (error) {
console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed'))
} finally {
setPlaylistLoading(false)
setPlaylistDownloadLoading(false)
}
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
// Auto-focus input on mount
useEffect(() => {
@@ -441,219 +537,275 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
style={{ maxWidth: '100%' }}
>
<Tabs defaultValue="single" className="w-full">
{/* <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList> */}
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6">
{/* URL Input Card */}
{!videoInfo && (
<Card>
<CardHeader>
<CardTitle>{t('download.enterUrl')}</CardTitle>
<Card>
<Tabs
defaultValue="single"
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
className="w-full gap-0"
>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle>
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
</CardTitle>
<CardDescription>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="px-0"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
{activeTab === 'single' ? (
<div className="flex flex-wrap items-center gap-2">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="p-0 h-auto"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
) : (
t('playlist.playlistUrlDescription')
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
placeholder={t('download.urlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
disabled={loading}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
{t('playlist.title')}
</TabsTrigger>
</TabsList>
</div>
</CardHeader>
<CardContent>
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6 mt-0">
{/* URL Input Card */}
{!videoInfo && (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
placeholder={t('download.urlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
disabled={loading}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
{t('download.paste')}
</Button>
{settings.oneClickDownload ? (
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('download.oneClickDownloadNow')}
</>
)}
</Button>
) : (
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
{t('download.fetch')}
</>
)}
</Button>
)}
</div>
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
{t('download.paste')}
</Button>
{settings.oneClickDownload ? (
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('download.oneClickDownloadNow')}
</>
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('download.oneClickDownloadEnabled')}</span>
</div>
{onOpenSettings && (
<Button
type="button"
variant="link"
className="h-auto px-2 py-0 text-xs"
onClick={onOpenSettings}
>
{t('download.goToSettings')}
</Button>
)}
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6 mt-0">
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => {
setPlaylistUrl(e.target.value)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}}
className="flex-1"
disabled={playlistBusy}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistBusy}
>
{t('download.paste')}
</Button>
) : (
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
{loading ? (
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}
variant="outline"
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
{t('download.fetch')}
</>
t('playlist.downloadPlaylist')
)}
</Button>
)}
</div>
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('download.oneClickDownload')}
</p>
<p className="text-sm text-blue-700">
{t('download.oneClickDownloadDescription')}
</p>
</div>
</div>
</div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</CardContent>
</Card>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
className="flex-1"
disabled={playlistLoading}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistLoading}
>
{t('download.paste')}
</Button>
</div>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistLoading}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
</div>
</div>
</div>
<Button
onClick={handleDownloadPlaylist}
className="w-full"
size="lg"
disabled={playlistLoading || !playlistUrl.trim()}
>
{playlistLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Playlist Preview Card (outside main card) */}
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/>
)}
{/* Unified Download History */}
<UnifiedDownloadHistory />

View File

@@ -21,22 +21,47 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
const clampSubscriptionInterval = (value: string) => {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
return 3
}
return Math.min(24, Math.max(1, parsed))
}
export function Settings() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom)
const [platform, setPlatform] = useState<string>('')
useEffect(() => {
loadSettings()
}, [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)
}
}
fetchPlatform()
}, [])
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
@@ -54,7 +79,7 @@ export function Settings() {
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
toast.error(t('settings.directorySelectError'))
}
}
@@ -67,7 +92,32 @@ export function Settings() {
}
} catch (error) {
console.error('Failed to select file:', error)
toast.error('Failed to select file')
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)
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)
toast.error(t('settings.openLinkError'))
}
}
@@ -100,7 +150,7 @@ export function Settings() {
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.downloadPath')}</ItemTitle>
<ItemDescription>Choose where to save downloaded files</ItemDescription>
<ItemDescription>{t('settings.downloadPathDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
@@ -115,9 +165,7 @@ export function Settings() {
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.theme')}</ItemTitle>
<ItemDescription>
Choose a light, dark, or system theme for VidBee
</ItemDescription>
<ItemDescription>{t('settings.themeDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
@@ -160,8 +208,7 @@ export function Settings() {
<ItemContent>
<ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>
<ItemDescription>
Choose the default download type for one-click downloads. Quality uses the
preset below.
{t('settings.oneClickDownloadTypeDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
@@ -185,9 +232,7 @@ export function Settings() {
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>
<ItemDescription>
Select the quality preset used for one-click downloads
</ItemDescription>
<ItemDescription>{t('settings.oneClickQualityDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
@@ -200,9 +245,6 @@ export function Settings() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t('settings.oneClickQualityOptions.auto')}
</SelectItem>
<SelectItem value="best">
{t('settings.oneClickQualityOptions.best')}
</SelectItem>
@@ -227,17 +269,38 @@ export function Settings() {
</ItemGroup>
<ItemGroup>
{platform === 'darwin' && (
<>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.hideDockIcon')}</ItemTitle>
<ItemDescription>{t('settings.hideDockIconDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.hideDockIcon}
onCheckedChange={(value) => handleSettingChange('hideDockIcon', value)}
/>
</ItemActions>
</Item>
<ItemSeparator />
</>
)}
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
<ItemTitle>{t('settings.launchAtLogin')}</ItemTitle>
<ItemDescription>
Display additional format options in the interface
{autoLaunchSupported
? t('settings.launchAtLoginDescription')
: t('settings.launchAtLoginUnsupported')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.showMoreFormats}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
checked={settings.launchAtLogin}
onCheckedChange={(value) => handleSettingChange('launchAtLogin', value)}
disabled={!autoLaunchSupported}
/>
</ItemActions>
</Item>
@@ -246,10 +309,54 @@ export function Settings() {
<TabsContent value="advanced" className="space-y-4 mt-2">
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
<ItemDescription>{t('settings.showMoreFormatsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.showMoreFormats}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
<ItemDescription>
{t('settings.subscriptionDefaults.intervalDescription')}
</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"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
<ItemDescription>Maximum number of simultaneous downloads</ItemDescription>
<ItemDescription>
{t('settings.maxConcurrentDownloadsDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
@@ -274,43 +381,14 @@ export function Settings() {
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
<ItemDescription>
Browser to extract cookies from for authentication
</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">Chrome</SelectItem>
<SelectItem value="firefox">Firefox</SelectItem>
<SelectItem value="edge">Edge</SelectItem>
<SelectItem value="safari">Safari</SelectItem>
<SelectItem value="brave">Brave</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.proxy')}</ItemTitle>
<ItemDescription>Proxy server for network requests</ItemDescription>
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Input
placeholder="http://proxy:port"
placeholder={t('settings.proxyPlaceholder')}
value={settings.proxy}
onChange={(e) => handleSettingChange('proxy', e.target.value)}
className="w-64"
@@ -323,16 +401,111 @@ export function Settings() {
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>Custom configuration file for yt-dlp</ItemDescription>
<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>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
<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>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.cookiesFile')}</ItemTitle>
<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>
</ItemActions>
</Item>
<ItemSeparator />
<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>
</ItemContent>
<ItemActions>
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
{t('settings.cookiesHelpFaq')}
</Button>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.enableAnalytics}
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
</Tabs>
</div>

View File

@@ -0,0 +1,665 @@
import {
type SubscriptionFormData,
SubscriptionFormDialog
} from '@renderer/components/subscription/SubscriptionFormDialog'
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 {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} 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 { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { ipcServices } from '@renderer/lib/ipc'
import { cn } from '@renderer/lib/utils'
import { type DownloadRecord, downloadsArrayAtom } from '@renderer/store/downloads'
import {
createSubscriptionAtom,
refreshSubscriptionAtom,
removeSubscriptionAtom,
resolveFeedAtom,
subscriptionsAtom,
updateSubscriptionAtom
} from '@renderer/store/subscriptions'
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
import dayjs from 'dayjs'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const statusStyles: Record<
SubscriptionRule['status'],
{ dotClass: string; textClass: string; label: string }
> = {
'up-to-date': {
dotClass: 'bg-emerald-500',
textClass: 'text-emerald-600',
label: 'subscriptions.status.up-to-date'
},
checking: {
dotClass: 'bg-sky-500',
textClass: 'text-sky-600',
label: 'subscriptions.status.checking'
},
failed: {
dotClass: 'bg-red-500',
textClass: 'text-red-600',
label: 'subscriptions.status.failed'
},
idle: {
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
label: 'subscriptions.status.idle'
}
}
const disabledStatusStyle = {
dotClass: 'bg-zinc-400',
textClass: 'text-muted-foreground',
label: 'subscriptions.fields.disabled'
}
type SubscriptionItemStatus = DownloadStatus | 'queued' | 'notQueued'
const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
notQueued: 'subscriptions.items.status.notQueued',
queued: 'subscriptions.items.status.queued',
pending: 'subscriptions.items.status.pending',
downloading: 'subscriptions.items.status.downloading',
processing: 'subscriptions.items.status.processing',
completed: 'subscriptions.items.status.completed',
error: 'subscriptions.items.status.error',
cancelled: 'subscriptions.items.status.cancelled'
}
function SubscriptionTab({
subscription,
onRefresh,
onRemove,
onUpdate,
isActive
}: SubscriptionTabProps) {
const { t } = useTranslation()
const [editOpen, setEditOpen] = useState(false)
const isDisabled = !subscription.enabled
const statusMeta = isDisabled ? disabledStatusStyle : statusStyles[subscription.status]
const statusDescription =
subscription.status === 'failed' && subscription.lastError
? subscription.lastError
: t(statusStyles[subscription.status].label)
const lastUpdatedTimestamp =
subscription.lastCheckedAt ?? subscription.updatedAt ?? subscription.createdAt ?? null
const lastUpdatedLabel = lastUpdatedTimestamp
? dayjs(lastUpdatedTimestamp).format('YYYY-MM-DD HH:mm')
: t('subscriptions.never')
const handleToggleEnabled = async (checked: boolean) => {
await onUpdate({ enabled: checked })
}
const handleRefresh = async () => {
await onRefresh()
toast.success(t('subscriptions.notifications.refreshStarted'))
}
const handleRemove = async () => {
await onRemove()
toast.success(t('subscriptions.notifications.removed'))
}
const handleEdit = () => {
setEditOpen(true)
}
return (
<>
<ContextMenu>
<HoverCard openDelay={0} closeDelay={0}>
<ContextMenuTrigger asChild>
<HoverCardTrigger asChild>
<TabsTrigger
value={subscription.id}
className={cn(
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
isActive && 'bg-muted/45'
)}
>
<div className="relative h-12 w-12 shrink-0 overflow-hidden transition-colors">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover rounded-full overflow-hidden"
/>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',
statusMeta.dotClass
)}
/>
</div>
<div className="flex w-full flex-col items-center text-center">
<span className="w-full truncate text-xs font-medium">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
</div>
</TabsTrigger>
</HoverCardTrigger>
</ContextMenuTrigger>
<HoverCardContent className="max-w-xs space-y-1">
<p className="text-sm font-semibold">
{subscription.title || t('subscriptions.labels.unknown')}
</p>
<p className="text-xs">{statusDescription}</p>
<p className="text-xs">
{t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
</p>
</HoverCardContent>
</HoverCard>
<ContextMenuContent>
<ContextMenuItem onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
{t('subscriptions.actions.refresh')}
</ContextMenuItem>
<ContextMenuItem onClick={handleEdit}>
<Edit className="h-4 w-4" />
{t('subscriptions.actions.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>
<Power className="h-4 w-4" />
{subscription.enabled
? t('subscriptions.actions.disable')
: t('subscriptions.actions.enable')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
<Trash2 className="h-4 w-4" />
{t('subscriptions.actions.remove')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SubscriptionFormDialog
mode="edit"
subscription={subscription}
open={editOpen}
onSave={async (data) => {
await onUpdate(data)
toast.success(t('subscriptions.notifications.updated'))
setEditOpen(false)
}}
onClose={() => setEditOpen(false)}
/>
</>
)
}
export function Subscriptions() {
const { t } = useTranslation()
const [subscriptions] = useAtom(subscriptionsAtom)
const updateSubscription = useSetAtom(updateSubscriptionAtom)
const removeSubscription = useSetAtom(removeSubscriptionAtom)
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedTab, setSelectedTab] = useState<string>('')
const sortedSubscriptions = useMemo(
() =>
[...subscriptions].sort(
(a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)
),
[subscriptions]
)
const resolveFeed = useSetAtom(resolveFeedAtom)
const handleUpdateSubscription = useCallback(
async (id: string, data: SubscriptionRuleUpdateForm) => {
const updatePayload: Parameters<typeof updateSubscription>[0]['data'] = {
keywords: data.keywords,
tags: data.tags,
onlyDownloadLatest: data.onlyDownloadLatest,
downloadDirectory: data.downloadDirectory,
namingTemplate: data.namingTemplate,
enabled: data.enabled
}
// If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform
if (data.url) {
try {
const resolved = await resolveFeed(data.url)
updatePayload.sourceUrl = resolved.sourceUrl
updatePayload.feedUrl = resolved.feedUrl
updatePayload.platform = resolved.platform
} catch (error) {
console.error('Failed to resolve feed URL:', error)
toast.error(t('subscriptions.notifications.resolveError'))
return
}
}
await updateSubscription({ id, data: updatePayload })
await refreshSubscription(id)
},
[refreshSubscription, updateSubscription, resolveFeed, t]
)
const createSubscription = useSetAtom(createSubscriptionAtom)
const handleCreateSubscription = useCallback(
async (data: SubscriptionFormData) => {
if (!data.url) {
toast.error(t('subscriptions.notifications.missingUrl'))
return
}
try {
await createSubscription({
url: data.url,
keywords: data.keywords?.join(', '),
tags: data.tags?.join(', '),
onlyDownloadLatest: data.onlyDownloadLatest,
downloadDirectory: data.downloadDirectory,
namingTemplate: data.namingTemplate,
enabled: data.enabled
})
toast.success(t('subscriptions.notifications.created'))
setAddDialogOpen(false)
} catch (error) {
console.error('Failed to create subscription:', error)
toast.error(t('subscriptions.notifications.createError'))
}
},
[createSubscription, t]
)
const handleOpenRSSHubDocs = useCallback(async () => {
try {
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
} catch (error) {
console.error('Failed to open RSSHub documentation:', error)
toast.error(t('subscriptions.notifications.openLinkError'))
}
}, [t])
// Filter subscriptions based on selected tab
const displayedSubscriptions = useMemo(() => {
if (!selectedTab) {
return []
}
return sortedSubscriptions.filter((sub) => sub.id === selectedTab)
}, [selectedTab, sortedSubscriptions])
// Set default tab to first subscription if available
useEffect(() => {
if (!selectedTab && sortedSubscriptions.length > 0) {
// Set to first subscription if no tab is selected
setSelectedTab(sortedSubscriptions[0].id)
} else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {
// If selected subscription no longer exists, switch to first available
if (sortedSubscriptions.length > 0) {
setSelectedTab(sortedSubscriptions[0].id)
} else {
setSelectedTab('')
}
}
}, [selectedTab, sortedSubscriptions])
return (
<div className="relative">
{/* Channel Tabs Header */}
<div className="">
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<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">
{/* Subscription Channel Tabs */}
{sortedSubscriptions.map((subscription) => (
<SubscriptionTab
key={subscription.id}
subscription={subscription}
isActive={subscription.id === selectedTab}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
/>
))}
{/* Add RSS Button */}
<Button
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! 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>
</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>
<SubscriptionFormDialog
mode="add"
open={addDialogOpen}
onSave={handleCreateSubscription}
onClose={() => setAddDialogOpen(false)}
/>
</div>
)
}
interface SubscriptionTabProps {
subscription: SubscriptionRule
isActive: boolean
onRefresh: () => Promise<void>
onRemove: () => Promise<void>
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
}
type SubscriptionRuleUpdateForm = SubscriptionFormData
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
const { t } = useTranslation()
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
const downloads = useAtomValue(downloadsArrayAtom)
const [historyStatusMap, setHistoryStatusMap] = useState<Record<string, DownloadStatus | null>>(
{}
)
const downloadLookup = useMemo(() => {
const map = new Map<string, DownloadRecord>()
downloads.forEach((record) => {
map.set(record.id, record)
})
return map
}, [downloads])
useEffect(() => {
const queuedDownloadIds = Array.from(
new Set(
feedItems
.filter((item) => item.addedToQueue && item.downloadId)
.map((item) => item.downloadId as string)
)
)
const missingIds = queuedDownloadIds.filter(
(downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined
)
if (missingIds.length === 0) {
return
}
let cancelled = false
const fetchHistoryStatuses = async () => {
try {
const results = await Promise.all(
missingIds.map(async (downloadId) => {
try {
const historyItem = await ipcServices.history.getHistoryById(downloadId)
return { downloadId, status: historyItem?.status ?? null }
} catch (error) {
console.error('Failed to fetch download history entry:', error)
return { downloadId, status: null }
}
})
)
if (cancelled) {
return
}
setHistoryStatusMap((prev) => {
let changed = false
const next = { ...prev }
for (const { downloadId, status } of results) {
if (next[downloadId] === status) {
continue
}
next[downloadId] = status
changed = true
}
return changed ? next : prev
})
} catch (error) {
console.error('Failed to resolve download history statuses:', error)
}
}
void fetchHistoryStatuses()
return () => {
cancelled = true
}
}, [feedItems, downloadLookup, historyStatusMap])
const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {
if (!item.addedToQueue) {
return 'notQueued'
}
if (!item.downloadId) {
return 'queued'
}
const matchedDownload = downloadLookup.get(item.downloadId)
if (!matchedDownload) {
const cachedHistoryStatus = historyStatusMap[item.downloadId]
if (cachedHistoryStatus) {
return cachedHistoryStatus
}
return 'queued'
}
return matchedDownload.status
}
const handleOpenItem = async (url: string) => {
try {
await ipcServices.fs.openExternal(url)
} catch (error) {
console.error('Failed to open subscription item link:', error)
toast.error(t('subscriptions.notifications.openLinkError'))
}
}
const handleQueueItem = useCallback(
async (item: SubscriptionFeedItem) => {
if (item.addedToQueue) {
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
return
}
try {
const queued = await ipcServices.subscriptions.queueItem(subscription.id, item.id)
if (queued) {
toast.success(t('subscriptions.notifications.itemQueued'))
return
}
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
} catch (error) {
console.error('Failed to queue subscription item:', error)
toast.error(t('subscriptions.notifications.queueError'))
}
},
[subscription.id, t]
)
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('subscriptions.items.empty')}
</div>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{feedItems.map((item) => {
const itemStatus = resolveItemStatus(item)
const hasResolvedDownloadStatus =
item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued'
const badgeLabel = item.addedToQueue
? t('subscriptions.items.status.queued')
: t('subscriptions.items.status.notQueued')
const tooltipLabel = item.addedToQueue
? hasResolvedDownloadStatus
? t('subscriptions.items.tooltip.downloadStatus', {
status: t(subscriptionItemStatusLabels[itemStatus])
})
: t('subscriptions.items.tooltip.downloadPending')
: t('subscriptions.items.tooltip.notQueued')
const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
return (
<ContextMenu key={`${subscription.id}-${item.id}`}>
<ContextMenuTrigger asChild>
<article className="group transition-all">
<div className="relative w-full overflow-hidden bg-muted aspect-video rounded-2xl">
{item.thumbnail ? (
<RemoteImage
src={item.thumbnail}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{t('subscriptions.labels.noThumbnail')}
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-black/5 to-transparent" />
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
{subscription.coverUrl ? (
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
</div>
)}
<span className="max-w-40 truncate text-xs">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
</div>
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
badgeClass
)}
>
{badgeLabel}
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipLabel}</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p
className="text-base font-semibold leading-snug text-card-foreground"
title={item.title}
>
{item.title}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="rounded-full px-4"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</article>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => void handleQueueItem(item)}
disabled={item.addedToQueue}
>
<Download className="h-4 w-4" />
{t('subscriptions.items.actions.queue')}
</ContextMenuItem>
<ContextMenuItem onClick={() => void handleOpenItem(item.url)}>
<ExternalLink className="h-4 w-4" />
{t('subscriptions.items.actions.open')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)
}

View File

@@ -6,11 +6,45 @@ import {
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { popularSites } from '@renderer/data/popularSites'
import { ipcServices } from '@renderer/lib/ipc'
import { ExternalLink } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
function SiteIcon({ domain, alt }: { domain: string; alt: string }) {
const [iconUrl, setIconUrl] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const loadIcon = async () => {
try {
const dataUrl = await ipcServices.app.getSiteIcon(domain)
if (!cancelled) {
setIconUrl(dataUrl)
}
} catch (error) {
console.error('Failed to load site icon:', error)
if (!cancelled) {
setIconUrl(null)
}
}
}
void loadIcon()
return () => {
cancelled = true
}
}, [domain])
return <RemoteImage src={iconUrl} alt={alt} className="w-full h-full" />
}
export function SupportedSites() {
const { t } = useTranslation()
@@ -28,15 +62,40 @@ export function SupportedSites() {
<section className="space-y-3">
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
<ul className="grid gap-3 sm:grid-cols-2">
{popularSites.map((site) => (
<li key={site.id} className="rounded-md border border-border px-6 py-5">
<p className="text-sm font-medium">{site.label}</p>
{site.description ? (
<p className="mt-1 text-xs text-muted-foreground">{site.description}</p>
) : null}
</li>
))}
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{popularSites.map((site) => {
const labelKey = `sites.popular.${site.id}.label`
const descriptionKey = `sites.popular.${site.id}.description`
const label = t(labelKey)
const description = t(descriptionKey)
const hasDescription = description !== descriptionKey
return (
<li key={site.id}>
<a
href={site.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-muted/50 hover:border-primary/50 group"
>
<div className="shrink-0 w-10 h-10 rounded-xs overflow-hidden">
<SiteIcon domain={site.domain} alt={label} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{label}</p>
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
{hasDescription ? (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
{description}
</p>
) : null}
</div>
</a>
</li>
)
})}
</ul>
</section>

View File

@@ -4,6 +4,8 @@ import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
export type DownloadRecord = DownloadItem & {
entryType: 'active' | 'history'
downloadedAt?: number
downloadPath?: string
savedFileName?: string
}
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
@@ -22,13 +24,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
status: item.status,
progress: undefined,
error: item.error,
outputPath: item.outputPath,
downloadPath: item.downloadPath,
speed: undefined,
duration: item.duration,
fileSize: item.fileSize,
format: item.format,
quality: item.quality,
codec: item.codec,
createdAt: item.downloadedAt,
startedAt: item.downloadedAt,
completedAt: item.completedAt ?? item.downloadedAt,
@@ -38,6 +37,11 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
viewCount: item.viewCount,
tags: item.tags,
selectedFormat: item.selectedFormat,
playlistId: item.playlistId,
playlistTitle: item.playlistTitle,
playlistIndex: item.playlistIndex,
playlistSize: item.playlistSize,
savedFileName: item.savedFileName,
entryType: 'history',
downloadedAt: item.downloadedAt
})

View File

@@ -1,6 +1,8 @@
import { atom } from 'jotai'
import { normalizeLanguageCode } from '../../../shared/languages'
import type { AppSettings } from '../../../shared/types'
import { defaultSettings } from '../../../shared/types'
import i18n from '../i18n'
import { ipcServices } from '../lib/ipc'
// Settings atom
@@ -10,7 +12,18 @@ export const settingsAtom = atom<AppSettings>(defaultSettings)
export const loadSettingsAtom = atom(null, async (_get, set) => {
try {
const settings = await ipcServices.settings.getAll()
set(settingsAtom, settings)
const savedLanguage = normalizeLanguageCode(settings.language)
const currentLanguage = normalizeLanguageCode(i18n.language)
if (currentLanguage !== savedLanguage) {
try {
await i18n.changeLanguage(savedLanguage)
} catch (error) {
console.error('Failed to apply saved language:', error)
}
}
set(settingsAtom, { ...settings, language: savedLanguage })
} catch (error) {
console.error('Failed to load settings:', error)
}

View File

@@ -0,0 +1,79 @@
import type {
SubscriptionResolvedFeed,
SubscriptionRule,
SubscriptionUpdatePayload
} from '@shared/types'
import { atom } from 'jotai'
import { ipcServices } from '../lib/ipc'
const normalizeCommaList = (value?: string): string[] => {
if (!value) {
return []
}
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
}
export const subscriptionsAtom = atom<SubscriptionRule[]>([])
export const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => {
set(subscriptionsAtom, subscriptions)
})
export const loadSubscriptionsAtom = atom(null, async (_get, set) => {
try {
const subscriptions = await ipcServices.subscriptions.list()
set(subscriptionsAtom, subscriptions)
} catch (error) {
console.error('Failed to load subscriptions:', error)
}
})
export interface CreateSubscriptionForm {
url: string
keywords?: string
tags?: string
onlyDownloadLatest?: boolean
downloadDirectory?: string
namingTemplate?: string
enabled?: boolean
}
export const createSubscriptionAtom = atom(
null,
async (_get, _set, payload: CreateSubscriptionForm) => {
await ipcServices.subscriptions.create({
url: payload.url,
keywords: normalizeCommaList(payload.keywords),
tags: normalizeCommaList(payload.tags),
onlyDownloadLatest: payload.onlyDownloadLatest,
downloadDirectory: payload.downloadDirectory,
namingTemplate: payload.namingTemplate,
enabled: payload.enabled
})
}
)
export const updateSubscriptionAtom = atom(
null,
async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => {
await ipcServices.subscriptions.update(update.id, update.data)
}
)
export const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => {
await ipcServices.subscriptions.remove(id)
})
export const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => {
await ipcServices.subscriptions.refresh(id)
})
export const resolveFeedAtom = atom(
null,
async (_get, _set, url: string): Promise<SubscriptionResolvedFeed> => {
return ipcServices.subscriptions.resolve(url)
}
)

2
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const APP_PROTOCOL = 'vidbee'
export const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`

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