Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b229225b6e | ||
|
|
5497ed6245 | ||
|
|
155bf652f2 | ||
|
|
86bd77d995 | ||
|
|
b38b356c22 | ||
|
|
5020605590 | ||
|
|
b15c3d8ce5 | ||
|
|
a51655697d | ||
|
|
f5c26f5f02 | ||
|
|
3bc9d76f6e | ||
|
|
5751d73d5b | ||
|
|
50bd9f1659 | ||
|
|
e7627e1132 | ||
|
|
d7b131d795 | ||
|
|
74dbb223ce | ||
|
|
11a931f865 | ||
|
|
79a78d4322 | ||
|
|
a1c44baf65 | ||
|
|
86e6b93476 | ||
|
|
dbcd77963f | ||
|
|
58a4d2da36 | ||
|
|
db16d5d051 | ||
|
|
41f40b6e71 | ||
|
|
1ee717888f | ||
|
|
c7815eb81a | ||
|
|
3946b5ef2f | ||
|
|
ef99eb1f59 | ||
|
|
31607bd947 | ||
|
|
452132ce16 | ||
|
|
f84f3ef02a | ||
|
|
6c3f070797 | ||
|
|
62a339e5b9 | ||
|
|
f1fd40176f | ||
|
|
e5d8629ba3 | ||
|
|
3375f94848 | ||
|
|
9f7206abfe | ||
|
|
334c7426c2 | ||
|
|
bd7f4a433c | ||
|
|
27287238aa | ||
|
|
486767fca9 |
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Bug Report
|
||||
description: Report a problem or regression
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: input
|
||||
id: app_version
|
||||
attributes:
|
||||
label: App version
|
||||
description: The VidBee version (e.g., 1.2.3)
|
||||
placeholder: 1.2.3
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os_version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
|
||||
placeholder: macOS 14.2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Observed behavior
|
||||
placeholder: What actually happened
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots
|
||||
description: Paste relevant logs or add screenshots
|
||||
placeholder: Attach files or paste logs here
|
||||
validations:
|
||||
required: false
|
||||
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What problem are you trying to solve?
|
||||
placeholder: I want to...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the feature or change you want
|
||||
placeholder: It would be great if...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other solutions or workarounds you considered
|
||||
placeholder: I tried...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots
|
||||
placeholder: Links, screenshots, or related issues
|
||||
validations:
|
||||
required: false
|
||||
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
@@ -76,10 +76,10 @@ jobs:
|
||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||
|
||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
@@ -103,7 +103,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
|
||||
exit 1
|
||||
fi
|
||||
mkdir ffmpeg
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
@@ -112,7 +116,7 @@ jobs:
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||
fi
|
||||
@@ -130,4 +134,3 @@ jobs:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -25,5 +25,8 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
3. Support i18n, only translate the English version of en.json
|
||||
4. use English for comments&console
|
||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
||||
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
|
||||
|
||||
155
README.md
155
README.md
@@ -1,36 +1,34 @@
|
||||
# 🐝 VidBee
|
||||
|
||||
<div align="center">
|
||||
<img src="build/icon.png" alt="VidBee icon" width="120" />
|
||||
<h3>Download videos from almost any website worldwide</h3>
|
||||
<p>Best-in-class UI interface - Clean, intuitive, and powerful</p>
|
||||
<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&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
|
||||
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
||||
<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>
|
||||
|
||||
## ✨ Core Features
|
||||
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, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
|
||||
|
||||
### 🌍 Global Video Download Support
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
|
||||
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
|
||||
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
|
||||
- Localized interface support in many languages
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
Feel free to try it using the following methods:
|
||||
|
||||
- **Modern Design** - Clean and beautiful interface
|
||||
- **Intuitive Operations** - One-click pause/resume/retry
|
||||
- **Real-time Progress** - Detailed download progress tracking and status management
|
||||
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
|
||||
|
||||
## 📥 Download & Install
|
||||
|
||||
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
|
||||
2. **Choose your platform**:
|
||||
- **Windows**: Download `vidbee-x.x.x-setup.exe`
|
||||
- **macOS**: Download `vidbee-x.x.x.dmg`
|
||||
- **Linux**: Download `vidbee-x.x.x.AppImage`
|
||||
3. **Install and run** the application
|
||||
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
|
||||
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
@@ -42,49 +40,102 @@ xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
|
||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||
|
||||
## 📸 Screenshots
|
||||
### 🌐 Browser Script (Quick Download)
|
||||
|
||||
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
|
||||
|
||||

|
||||
|
||||
**Installation:**
|
||||
|
||||
1. Install a userscript manager extension:
|
||||
- [Tampermonkey](https://www.tampermonkey.net/) (Recommended)
|
||||
- [Violentmonkey](https://violentmonkey.github.io/)
|
||||
- [Greasemonkey](https://www.greasespot.net/)
|
||||
|
||||
2. Install the VidBee Quick Download script:
|
||||
- [Install from Greasy Fork](https://greasyfork.org/zh-CN/scripts/559595-vidbee-quick-download)
|
||||
|
||||
**Usage:**
|
||||
|
||||
After installation, when you visit supported video websites (YouTube, Bilibili, TikTok, Instagram, Twitter, etc.), a download button will appear on the page. Click the button to quickly send the video URL to VidBee desktop app for downloading.
|
||||
|
||||
**Supported Sites:**
|
||||
|
||||
The script works on popular video platforms including:
|
||||
|
||||
- YouTube, Bilibili, TikTok, Vimeo, Dailymotion
|
||||
- Twitch, Twitter/X, Instagram, Facebook
|
||||
- Reddit, SoundCloud, Niconico, Kick
|
||||
- Bandcamp, Mixcloud, and more
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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.
|
||||
|
||||

|
||||
*Clean and intuitive interface with download queue management*
|
||||
|
||||
### 🎨 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.
|
||||
|
||||

|
||||
*Comprehensive download queue with progress tracking and status management*
|
||||
|
||||
### 📡 RSS Auto Download
|
||||
|
||||
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
|
||||
|
||||
## 🌐 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 | |
|
||||
| :--------------- | :---------------------- |
|
||||
| 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
|
||||
## 🤝 Contributing
|
||||
|
||||
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
|
||||
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` for details.
|
||||
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [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) - 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
|
||||
|
||||
13
biome.json
13
biome.json
@@ -39,7 +39,18 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
"includes": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.json",
|
||||
"!monkey/dist",
|
||||
"!dist",
|
||||
"!out",
|
||||
"!build"
|
||||
],
|
||||
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
|
||||
6
conductor.json
Normal file
6
conductor.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "cp -r $CONDUCTOR_ROOT_PATH/resources resources/ && pnpm install",
|
||||
"run": "pnpm run dev"
|
||||
}
|
||||
}
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal 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'
|
||||
})
|
||||
@@ -2,6 +2,10 @@ appId: com.vidbee
|
||||
productName: VidBee
|
||||
directories:
|
||||
buildResources: build
|
||||
protocols:
|
||||
- name: VidBee
|
||||
schemes:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
@@ -19,10 +23,15 @@ nsis:
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
identity: null
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
@@ -41,3 +50,5 @@ publish:
|
||||
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
electronLanguages:
|
||||
- en
|
||||
|
||||
23
monkey/.gitignore
vendored
Normal file
23
monkey/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
// ==UserScript==
|
||||
// @name vidbee-quick-download
|
||||
// @namespace vidbee
|
||||
// @version 0.0.1
|
||||
// @icon https://vidbee.org/favicon.svg
|
||||
// @match https://www.youtube.com/*
|
||||
// @match https://youtube.com/*
|
||||
// @match https://music.youtube.com/*
|
||||
// @match https://www.bilibili.com/*
|
||||
// @match https://bilibili.com/*
|
||||
// @match https://www.tiktok.com/*
|
||||
// @match https://tiktok.com/*
|
||||
// @match https://vimeo.com/*
|
||||
// @match https://www.vimeo.com/*
|
||||
// @match https://www.dailymotion.com/*
|
||||
// @match https://dailymotion.com/*
|
||||
// @match https://www.twitch.tv/*
|
||||
// @match https://twitch.tv/*
|
||||
// @match https://twitter.com/*
|
||||
// @match https://www.twitter.com/*
|
||||
// @match https://x.com/*
|
||||
// @match https://www.x.com/*
|
||||
// @match https://www.instagram.com/*
|
||||
// @match https://instagram.com/*
|
||||
// @match https://www.facebook.com/*
|
||||
// @match https://facebook.com/*
|
||||
// @match https://fb.com/*
|
||||
// @match https://www.fb.com/*
|
||||
// @match https://www.reddit.com/*
|
||||
// @match https://reddit.com/*
|
||||
// @match https://soundcloud.com/*
|
||||
// @match https://www.soundcloud.com/*
|
||||
// @match https://www.nicovideo.jp/*
|
||||
// @match https://nicovideo.jp/*
|
||||
// @match https://kick.com/*
|
||||
// @match https://www.kick.com/*
|
||||
// @match https://bandcamp.com/*
|
||||
// @match https://*.bandcamp.com/*
|
||||
// @match https://www.mixcloud.com/*
|
||||
// @match https://mixcloud.com/*
|
||||
// @grant GM_addStyle
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));};
|
||||
|
||||
const styleCss = '.vidbee-download-container{position:fixed;bottom:16px;right:16px;z-index:9999;transition:all .2s ease;overflow:visible}.vidbee-download-container.vidbee-hidden{opacity:0;pointer-events:none;transform:scale(0)}.vidbee-download-button{position:relative;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;background:#00000080;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fffc;border:1px solid rgba(255,255,255,.1);border-radius:50%;box-shadow:0 2px 8px #0003;cursor:pointer;font-size:0;transition:all .2s ease;opacity:.6;overflow:visible}.vidbee-download-button:hover{opacity:1;background:#000000b3;border-color:#fff3;box-shadow:0 4px 12px #0000004d;transform:scale(1.1)}.vidbee-download-button:active{transform:scale(.95)}.vidbee-download-button svg{flex-shrink:0;stroke:currentColor;width:16px;height:16px}.vidbee-tooltip{position:absolute;right:calc(100% + 8px);top:50%;padding:6px 10px;background:#000000e6;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:500;white-space:nowrap;border-radius:4px;box-shadow:0 2px 8px #0000004d;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(-50%) translate(4px);z-index:10000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1}.vidbee-tooltip:after{content:"";position:absolute;left:100%;top:50%;transform:translateY(-50%);border:4px solid transparent;border-left-color:#000000e6}.vidbee-download-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}.vidbee-close-button{position:absolute;top:-6px;right:-6px;display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;background:#ff4d4de6;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;box-shadow:0 2px 4px #0000004d;cursor:pointer;font-size:0;transition:all .2s ease;z-index:1;opacity:0;pointer-events:none;overflow:visible}.vidbee-download-container:hover .vidbee-close-button{opacity:1;pointer-events:auto}.vidbee-close-button:hover{background:#ff4d4d;transform:scale(1.15);box-shadow:0 3px 6px #0006}.vidbee-close-button:active{transform:scale(.9)}.vidbee-close-button svg{flex-shrink:0;stroke:currentColor;width:10px;height:10px}.vidbee-close-button .vidbee-tooltip{right:calc(100% + 6px);top:50%;left:auto;transform:translateY(-50%) translate(4px)}.vidbee-close-button .vidbee-tooltip:after{left:100%;top:50%;right:auto;transform:translateY(-50%);border-left-color:#000000e6;border-top-color:transparent}.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}';
|
||||
importCSS(styleCss);
|
||||
function getVideoUrl() {
|
||||
return window.location.href;
|
||||
}
|
||||
function hideButtonTemporarily() {
|
||||
const container = document.getElementById("vidbee-download-btn");
|
||||
if (container) {
|
||||
container.classList.add("vidbee-hidden");
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove("vidbee-hidden");
|
||||
}
|
||||
}, 5e3);
|
||||
}
|
||||
}
|
||||
function createVidBeeButton() {
|
||||
if (document.getElementById("vidbee-download-btn")) {
|
||||
return;
|
||||
}
|
||||
const videoUrl = getVideoUrl();
|
||||
if (!videoUrl) {
|
||||
return;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.id = "vidbee-download-btn";
|
||||
container.className = "vidbee-download-container";
|
||||
const button = document.createElement("button");
|
||||
button.className = "vidbee-download-button";
|
||||
button.setAttribute("aria-label", "Download with VidBee");
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`;
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.className = "vidbee-close-button";
|
||||
closeButton.setAttribute("aria-label", "Hide button");
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`;
|
||||
closeButton.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
hideButtonTemporarily();
|
||||
});
|
||||
let clickTimer = null;
|
||||
let clickCount = 0;
|
||||
button.addEventListener("click", () => {
|
||||
clickCount++;
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`;
|
||||
window.location.href = vidbeeUrl;
|
||||
clickCount = 0;
|
||||
}, 300);
|
||||
} else if (clickCount === 2) {
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer);
|
||||
}
|
||||
clickCount = 0;
|
||||
hideButtonTemporarily();
|
||||
}
|
||||
});
|
||||
container.appendChild(button);
|
||||
container.appendChild(closeButton);
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
function init() {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createVidBeeButton);
|
||||
} else {
|
||||
createVidBeeButton();
|
||||
}
|
||||
let lastUrl = location.href;
|
||||
let urlCheckTimer = null;
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href;
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl;
|
||||
const oldButton = document.getElementById("vidbee-download-btn");
|
||||
if (oldButton) {
|
||||
oldButton.remove();
|
||||
}
|
||||
const hostname = window.location.hostname;
|
||||
const delay = hostname.includes("bilibili.com") ? 800 : 500;
|
||||
setTimeout(createVidBeeButton, delay);
|
||||
}
|
||||
};
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer);
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener("popstate", () => {
|
||||
setTimeout(checkUrlChange, 300);
|
||||
});
|
||||
}
|
||||
init();
|
||||
|
||||
})();
|
||||
16
monkey/package.json
Normal file
16
monkey/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "vidbee-quick-download",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3",
|
||||
"vite-plugin-monkey": "^7.1.1"
|
||||
}
|
||||
}
|
||||
943
monkey/pnpm-lock.yaml
generated
Normal file
943
monkey/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,943 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.1.3
|
||||
version: 7.3.0
|
||||
vite-plugin-monkey:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.8(postcss@8.5.6)(vite@7.3.0)
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cuint@0.2.2:
|
||||
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.4.0:
|
||||
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
import-meta-resolve@4.2.0:
|
||||
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-wsl@3.1.0:
|
||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@3.1.0:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
mime@2.5.2:
|
||||
resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimatch@3.0.8:
|
||||
resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss-url@10.1.3:
|
||||
resolution: {integrity: sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rollup@4.54.0:
|
||||
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
systemjs@6.15.1:
|
||||
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite-plugin-monkey@7.1.8:
|
||||
resolution: {integrity: sha512-FGz1jlHcodt+L120UcHLiN0jXqQZdZHxLuHcGQg8Zjas6BulxLM1oiGUg3M95HbjYVFH6ygrOJoZ5YCYu1purw==}
|
||||
peerDependencies:
|
||||
vite: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cuint@0.2.2: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.4.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
'@esbuild/android-arm': 0.27.2
|
||||
'@esbuild/android-arm64': 0.27.2
|
||||
'@esbuild/android-x64': 0.27.2
|
||||
'@esbuild/darwin-arm64': 0.27.2
|
||||
'@esbuild/darwin-x64': 0.27.2
|
||||
'@esbuild/freebsd-arm64': 0.27.2
|
||||
'@esbuild/freebsd-x64': 0.27.2
|
||||
'@esbuild/linux-arm': 0.27.2
|
||||
'@esbuild/linux-arm64': 0.27.2
|
||||
'@esbuild/linux-ia32': 0.27.2
|
||||
'@esbuild/linux-loong64': 0.27.2
|
||||
'@esbuild/linux-mips64el': 0.27.2
|
||||
'@esbuild/linux-ppc64': 0.27.2
|
||||
'@esbuild/linux-riscv64': 0.27.2
|
||||
'@esbuild/linux-s390x': 0.27.2
|
||||
'@esbuild/linux-x64': 0.27.2
|
||||
'@esbuild/netbsd-arm64': 0.27.2
|
||||
'@esbuild/netbsd-x64': 0.27.2
|
||||
'@esbuild/openbsd-arm64': 0.27.2
|
||||
'@esbuild/openbsd-x64': 0.27.2
|
||||
'@esbuild/openharmony-arm64': 0.27.2
|
||||
'@esbuild/sunos-x64': 0.27.2
|
||||
'@esbuild/win32-arm64': 0.27.2
|
||||
'@esbuild/win32-ia32': 0.27.2
|
||||
'@esbuild/win32-x64': 0.27.2
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
import-meta-resolve@4.2.0: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-wsl@3.1.0:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
|
||||
mime@2.5.2: {}
|
||||
|
||||
minimatch@3.0.8:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.4.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
postcss-url@10.1.3(postcss@8.5.6):
|
||||
dependencies:
|
||||
make-dir: 3.1.0
|
||||
mime: 2.5.2
|
||||
minimatch: 3.0.8
|
||||
postcss: 8.5.6
|
||||
xxhashjs: 0.2.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rollup@4.54.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.54.0
|
||||
'@rollup/rollup-android-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-x64': 4.54.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.54.0
|
||||
'@rollup/rollup-freebsd-x64': 4.54.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.54.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.54.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.54.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
systemjs@6.15.1: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-plugin-monkey@7.1.8(postcss@8.5.6)(vite@7.3.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
cross-spawn: 7.0.6
|
||||
htmlparser2: 10.0.0
|
||||
import-meta-resolve: 4.2.0
|
||||
magic-string: 0.30.21
|
||||
mrmime: 2.0.1
|
||||
open: 10.2.0
|
||||
picocolors: 1.1.1
|
||||
postcss-url: 10.1.3(postcss@8.5.6)
|
||||
systemjs: 6.15.1
|
||||
optionalDependencies:
|
||||
vite: 7.3.0
|
||||
transitivePeerDependencies:
|
||||
- postcss
|
||||
|
||||
vite@7.3.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.54.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
dependencies:
|
||||
cuint: 0.2.2
|
||||
146
monkey/src/main.ts
Normal file
146
monkey/src/main.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import './style.css'
|
||||
|
||||
// Get current video URL
|
||||
// yt-dlp can handle all URLs directly, so we just return the current URL
|
||||
function getVideoUrl(): string | null {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
// Temporarily hide button
|
||||
function hideButtonTemporarily(): void {
|
||||
const container = document.getElementById('vidbee-download-btn')
|
||||
if (container) {
|
||||
container.classList.add('vidbee-hidden')
|
||||
// Auto restore after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove('vidbee-hidden')
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create VidBee download button
|
||||
function createVidBeeButton(): void {
|
||||
// Check if button already exists
|
||||
if (document.getElementById('vidbee-download-btn')) {
|
||||
return
|
||||
}
|
||||
|
||||
const videoUrl = getVideoUrl()
|
||||
if (!videoUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create button container
|
||||
const container = document.createElement('div')
|
||||
container.id = 'vidbee-download-btn'
|
||||
container.className = 'vidbee-download-container'
|
||||
|
||||
// Create main download button
|
||||
const button = document.createElement('button')
|
||||
button.className = 'vidbee-download-button'
|
||||
button.setAttribute('aria-label', 'Download with VidBee')
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.className = 'vidbee-close-button'
|
||||
closeButton.setAttribute('aria-label', 'Hide button')
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`
|
||||
|
||||
// Handle close button click - temporarily hide
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
hideButtonTemporarily()
|
||||
})
|
||||
|
||||
let clickTimer: number | null = null
|
||||
let clickCount = 0
|
||||
|
||||
// Handle main button click event - single click for download
|
||||
button.addEventListener('click', () => {
|
||||
clickCount++
|
||||
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
// Single click - trigger download
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
clickCount = 0
|
||||
}, 300)
|
||||
} else if (clickCount === 2) {
|
||||
// Double click - temporarily hide
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer)
|
||||
}
|
||||
clickCount = 0
|
||||
hideButtonTemporarily()
|
||||
}
|
||||
})
|
||||
|
||||
// Assemble container
|
||||
container.appendChild(button)
|
||||
container.appendChild(closeButton)
|
||||
|
||||
// Insert container directly to body (fixed position)
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
function init(): void {
|
||||
// Wait for page to fully load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createVidBeeButton)
|
||||
} else {
|
||||
createVidBeeButton()
|
||||
}
|
||||
|
||||
// Handle SPA navigation (when navigating between videos on sites like YouTube, Bilibili, etc.)
|
||||
let lastUrl = location.href
|
||||
let urlCheckTimer: number | null = null
|
||||
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl
|
||||
// Remove old button and create new one
|
||||
const oldButton = document.getElementById('vidbee-download-btn')
|
||||
if (oldButton) {
|
||||
oldButton.remove()
|
||||
}
|
||||
// Wait a bit for the page to update (different sites have different update speeds)
|
||||
const hostname = window.location.hostname
|
||||
const delay = hostname.includes('bilibili.com') ? 800 : 500
|
||||
setTimeout(createVidBeeButton, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Use MutationObserver for DOM changes (works for most SPA sites)
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer)
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100)
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
// Also listen to popstate for browser navigation
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(checkUrlChange, 300)
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
169
monkey/src/style.css
Normal file
169
monkey/src/style.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* VidBee Download Button Container */
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hidden state */
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Main Download Button */
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
/* Close Button - Hidden by default */
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Show close button on container hover */
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Close button tooltip positioning */
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
4
monkey/src/vite-env.d.ts
vendored
Normal file
4
monkey/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-monkey/client" />
|
||||
//// <reference types="vite-plugin-monkey/global" />
|
||||
/// <reference types="vite-plugin-monkey/style" />
|
||||
24
monkey/tsconfig.json
Normal file
24
monkey/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
monkey/tsconfig.node.json
Normal file
13
monkey/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
67
monkey/vite.config.ts
Normal file
67
monkey/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import monkey from 'vite-plugin-monkey'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
monkey({
|
||||
entry: 'src/main.ts',
|
||||
userscript: {
|
||||
icon: 'https://vidbee.org/favicon.svg',
|
||||
namespace: 'vidbee',
|
||||
match: [
|
||||
// YouTube
|
||||
'https://www.youtube.com/*',
|
||||
'https://youtube.com/*',
|
||||
'https://music.youtube.com/*',
|
||||
// Bilibili (哔哩哔哩)
|
||||
'https://www.bilibili.com/*',
|
||||
'https://bilibili.com/*',
|
||||
// TikTok
|
||||
'https://www.tiktok.com/*',
|
||||
'https://tiktok.com/*',
|
||||
// Vimeo
|
||||
'https://vimeo.com/*',
|
||||
'https://www.vimeo.com/*',
|
||||
// Dailymotion
|
||||
'https://www.dailymotion.com/*',
|
||||
'https://dailymotion.com/*',
|
||||
// Twitch
|
||||
'https://www.twitch.tv/*',
|
||||
'https://twitch.tv/*',
|
||||
// Twitter/X
|
||||
'https://twitter.com/*',
|
||||
'https://www.twitter.com/*',
|
||||
'https://x.com/*',
|
||||
'https://www.x.com/*',
|
||||
// Instagram
|
||||
'https://www.instagram.com/*',
|
||||
'https://instagram.com/*',
|
||||
// Facebook
|
||||
'https://www.facebook.com/*',
|
||||
'https://facebook.com/*',
|
||||
'https://fb.com/*',
|
||||
'https://www.fb.com/*',
|
||||
// Reddit
|
||||
'https://www.reddit.com/*',
|
||||
'https://reddit.com/*',
|
||||
// SoundCloud
|
||||
'https://soundcloud.com/*',
|
||||
'https://www.soundcloud.com/*',
|
||||
// NicoNico
|
||||
'https://www.nicovideo.jp/*',
|
||||
'https://nicovideo.jp/*',
|
||||
// Kick
|
||||
'https://kick.com/*',
|
||||
'https://www.kick.com/*',
|
||||
// Bandcamp
|
||||
'https://bandcamp.com/*',
|
||||
'https://*.bandcamp.com/*',
|
||||
// Mixcloud
|
||||
'https://www.mixcloud.com/*',
|
||||
'https://mixcloud.com/*'
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.3.5",
|
||||
"version": "1.1.2",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
@@ -15,11 +15,12 @@
|
||||
"build": "electron-vite build",
|
||||
"setup": "node scripts/setup-dev-binaries.js",
|
||||
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp"
|
||||
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
|
||||
"build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
||||
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -27,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",
|
||||
@@ -41,9 +44,11 @@
|
||||
"@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",
|
||||
@@ -56,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"
|
||||
},
|
||||
@@ -71,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",
|
||||
|
||||
644
pnpm-lock.yaml
generated
644
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
resources/.gitignore
vendored
2
resources/.gitignore
vendored
@@ -6,6 +6,8 @@ ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
deno.exe
|
||||
deno
|
||||
|
||||
# But keep the README
|
||||
!README.md
|
||||
|
||||
@@ -69,3 +69,23 @@ ffmpeg is required for merging audio/video streams and audio extraction. Bundle
|
||||
- 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
|
||||
|
||||
## JS Runtime (Deno)
|
||||
|
||||
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
|
||||
|
||||
### Required Files
|
||||
|
||||
1. **Windows**: `deno.exe`
|
||||
2. **macOS**: `deno`
|
||||
3. **Linux**: `deno`
|
||||
|
||||
### How to Download
|
||||
|
||||
- Visit: <https://github.com/denoland/deno/releases/latest>
|
||||
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
|
||||
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
|
||||
|
||||
### Note
|
||||
|
||||
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.
|
||||
|
||||
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal 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
|
||||
);
|
||||
451
resources/drizzle/meta/0000_snapshot.json
Normal file
451
resources/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
resources/drizzle/meta/_journal.json
Normal file
13
resources/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1763176841336,
|
||||
"tag": "0000_swift_aaron_stack",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
screenshots/browser-script.png
Normal file
BIN
screenshots/browser-script.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
@@ -44,6 +44,17 @@ const binaries = [
|
||||
linux: 'https://ffmpeg.org/download.html',
|
||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'deno',
|
||||
filenameMap: {
|
||||
win: 'deno.exe',
|
||||
mac: 'deno',
|
||||
linux: 'deno'
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/denoland/deno/releases/latest'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ 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'
|
||||
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||
const GITHUB_TOKEN =
|
||||
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||
|
||||
// Platform configuration
|
||||
const PLATFORM_CONFIG = {
|
||||
@@ -27,7 +30,12 @@ const PLATFORM_CONFIG = {
|
||||
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'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
}
|
||||
},
|
||||
darwin: {
|
||||
@@ -41,13 +49,21 @@ const PLATFORM_CONFIG = {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-arm64.*\.zip$/i
|
||||
}
|
||||
},
|
||||
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'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-x86_64.*\.zip$/i
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +76,12 @@ const PLATFORM_CONFIG = {
|
||||
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'
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +138,85 @@ function downloadFile(url, dest) {
|
||||
})
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const headers = {
|
||||
'User-Agent': 'vidbee-setup',
|
||||
Accept: 'application/vnd.github+json'
|
||||
}
|
||||
if (GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||
}
|
||||
|
||||
protocol
|
||||
.get(url, { headers }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
return fetchJson(response.headers.location).then(resolve).catch(reject)
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
let body = ''
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inferFfmpegInnerPath(assetName, binaryName) {
|
||||
if (!assetName) {
|
||||
return null
|
||||
}
|
||||
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return `${match[1]}/bin/${binaryName}`
|
||||
}
|
||||
|
||||
async function resolveReleaseAsset(release) {
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
|
||||
if (repoCandidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let lastError
|
||||
for (const repo of repoCandidates) {
|
||||
try {
|
||||
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
|
||||
const assets = Array.isArray(data.assets) ? data.assets : []
|
||||
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
|
||||
if (match?.browser_download_url) {
|
||||
return { name: match.name, url: match.browser_download_url }
|
||||
}
|
||||
lastError = new Error(`No matching assets found in ${repo}`)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractZip(zipPath, extractDir) {
|
||||
const platform = os.platform()
|
||||
ensureDir(extractDir)
|
||||
@@ -158,6 +258,32 @@ function fileExists(filePath) {
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
function getDenoAssetName(platform, arch) {
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-pc-windows-msvc.zip'
|
||||
}
|
||||
return 'deno-x86_64-pc-windows-msvc.zip'
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-apple-darwin.zip'
|
||||
}
|
||||
return 'deno-x86_64-apple-darwin.zip'
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
if (arch === 'arm64') {
|
||||
return 'deno-aarch64-unknown-linux-gnu.zip'
|
||||
}
|
||||
return 'deno-x86_64-unknown-linux-gnu.zip'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDenoOutputName(platform) {
|
||||
return platform === 'win32' ? 'deno.exe' : 'deno'
|
||||
}
|
||||
|
||||
// Main download functions
|
||||
async function downloadYtDlp(config) {
|
||||
const { asset, output } = config.ytdlp
|
||||
@@ -186,7 +312,7 @@ async function downloadYtDlp(config) {
|
||||
}
|
||||
|
||||
async function downloadFfmpegWindows(config) {
|
||||
const { url, innerPath, output } = config.ffmpeg
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -197,9 +323,26 @@ async function downloadFfmpegWindows(config) {
|
||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempZip)
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -229,7 +372,7 @@ async function downloadFfmpegMac(config) {
|
||||
throw new Error(`Unsupported architecture: ${arch}`)
|
||||
}
|
||||
|
||||
const { url, innerPath, output } = ffmpegConfig
|
||||
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -240,9 +383,21 @@ async function downloadFfmpegMac(config) {
|
||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempZip)
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -266,7 +421,7 @@ async function downloadFfmpegMac(config) {
|
||||
}
|
||||
|
||||
async function downloadFfmpegLinux(config) {
|
||||
const { url, innerPath, output } = config.ffmpeg
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -277,9 +432,26 @@ async function downloadFfmpegLinux(config) {
|
||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
const resolved = await resolveReleaseAsset(release)
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.url
|
||||
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempTar)
|
||||
await downloadFile(downloadUrl, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
@@ -302,6 +474,52 @@ async function downloadFfmpegLinux(config) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDenoRuntime() {
|
||||
const platform = os.platform()
|
||||
const arch = os.arch()
|
||||
const assetName = getDenoAssetName(platform, arch)
|
||||
|
||||
if (!assetName) {
|
||||
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
|
||||
return
|
||||
}
|
||||
|
||||
const outputName = getDenoOutputName(platform)
|
||||
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${outputName} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
|
||||
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting Deno runtime...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, outputName)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`Deno binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${outputName} successfully`, 'success')
|
||||
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Main setup function
|
||||
async function setup() {
|
||||
const platform = os.platform()
|
||||
@@ -319,6 +537,9 @@ async function setup() {
|
||||
// Download yt-dlp
|
||||
await downloadYtDlp(config)
|
||||
|
||||
// Download JS runtime (Deno)
|
||||
await downloadDenoRuntime()
|
||||
|
||||
// Download ffmpeg
|
||||
if (platform === 'win32') {
|
||||
await downloadFfmpegWindows(config)
|
||||
|
||||
@@ -5,15 +5,6 @@ import log from 'electron-log/main'
|
||||
* Set log format, file path, transport methods, etc.
|
||||
*/
|
||||
export function configureLogger() {
|
||||
// Configure console output format - support colors and scope, time in gray
|
||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
||||
|
||||
// Enable console colors
|
||||
log.transports.console.useStyles = true
|
||||
|
||||
// Configure file output format - include scope information
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
||||
|
||||
// Set log levels
|
||||
// Development: show all logs
|
||||
// Production: show info level and above only
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
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()
|
||||
if (!trimmed) {
|
||||
return '%(title)s via VidBee.%(ext)s'
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/')
|
||||
const safeParts = normalized
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '' && part !== '.' && part !== '..')
|
||||
.map((part) => part.replace(/[<>:"|?*]/g, '-').replace(/[. ]+$/g, ''))
|
||||
.filter((part) => part !== '')
|
||||
return safeParts.length === 0 ? '%(title)s via VidBee.%(ext)s' : safeParts.join('/')
|
||||
}
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
|
||||
if (format && audioFormat === '') {
|
||||
return format
|
||||
}
|
||||
|
||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
return format
|
||||
}
|
||||
@@ -45,7 +65,8 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
||||
export const buildDownloadArgs = (
|
||||
options: DownloadOptions,
|
||||
downloadPath: string,
|
||||
settings: AppSettings
|
||||
settings: AppSettings,
|
||||
jsRuntimeArgs: string[] = []
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
|
||||
@@ -80,7 +101,12 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
|
||||
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
|
||||
@@ -104,8 +130,13 @@ export const buildDownloadArgs = (
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', settings.configPath)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
if (jsRuntimeArgs.length > 0) {
|
||||
args.push(...jsRuntimeArgs)
|
||||
}
|
||||
|
||||
args.push(options.url)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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, type BrowserWindowConstructorOptions, shell } from 'electron'
|
||||
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'
|
||||
@@ -8,9 +10,12 @@ 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
|
||||
@@ -19,8 +24,107 @@ 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
|
||||
interface DeepLinkData {
|
||||
url: string
|
||||
type: 'single' | 'playlist'
|
||||
}
|
||||
const pendingDeepLinkUrls: DeepLinkData[] = []
|
||||
let isRendererReady = false
|
||||
|
||||
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
|
||||
try {
|
||||
const parsed = new URL(rawUrl)
|
||||
if (parsed.protocol !== `${APP_PROTOCOL}:`) {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = parsed.hostname
|
||||
const path = parsed.pathname.replace(/^\/+/, '')
|
||||
const isDownloadLink = host === 'download' || path.startsWith('download')
|
||||
if (!isDownloadLink) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetUrl = parsed.searchParams.get('url')
|
||||
if (!targetUrl || !targetUrl.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typeParam = parsed.searchParams.get('type')
|
||||
const type = typeParam === 'playlist' ? 'playlist' : 'single'
|
||||
|
||||
return {
|
||||
url: targetUrl.trim(),
|
||||
type
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to parse deep link:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const deliverDeepLink = (data: DeepLinkData): void => {
|
||||
if (!mainWindow || !isRendererReady) {
|
||||
pendingDeepLinkUrls.push(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('download:deeplink', data)
|
||||
}
|
||||
|
||||
const flushPendingDeepLinks = (): void => {
|
||||
if (!mainWindow || !isRendererReady || pendingDeepLinkUrls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length)
|
||||
for (const data of pending) {
|
||||
mainWindow.webContents.send('download:deeplink', data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeepLinkUrl = (rawUrl: string): void => {
|
||||
const data = parseDownloadDeepLink(rawUrl)
|
||||
if (!data) {
|
||||
log.warn('Ignored unsupported deep link:', rawUrl)
|
||||
return
|
||||
}
|
||||
deliverDeepLink(data)
|
||||
}
|
||||
|
||||
const handleDeepLinkArgv = (argv: string[]): void => {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith(`${APP_PROTOCOL}://`)) {
|
||||
handleDeepLinkUrl(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
@@ -77,9 +181,15 @@ 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())
|
||||
isRendererReady = true
|
||||
flushPendingDeepLinks()
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -106,6 +216,66 @@ 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...')
|
||||
@@ -147,9 +317,7 @@ function initAutoUpdater(): void {
|
||||
|
||||
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'
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -171,6 +339,28 @@ function initAutoUpdater(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
handleDeepLinkArgv(argv)
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleDeepLinkUrl(url)
|
||||
})
|
||||
|
||||
// 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.
|
||||
@@ -178,6 +368,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) => {
|
||||
@@ -206,6 +403,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
createWindow()
|
||||
|
||||
@@ -214,6 +412,10 @@ app.whenReady().then(async () => {
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
handleDeepLinkArgv(process.argv)
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os from 'node:os'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { scopedLoggers } from '../../utils/logger'
|
||||
|
||||
class AppService extends IpcService {
|
||||
static readonly groupName = 'app'
|
||||
@@ -32,6 +33,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) {
|
||||
scopedLoggers.system.error('Failed to fetch site icon:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AppService }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { clipboard, dialog, shell } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { scopedLoggers } from '../../utils/logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -49,7 +50,10 @@ class FileSystemService extends IpcService {
|
||||
return xdgPath
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
|
||||
scopedLoggers.system.warn(
|
||||
'Unable to resolve XDG download directory, falling back to default:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ class FileSystemService extends IpcService {
|
||||
if (stats?.isDirectory()) {
|
||||
const result = await shell.openPath(normalizedPath)
|
||||
if (result) {
|
||||
console.error('Failed to open directory:', result)
|
||||
scopedLoggers.system.error('Failed to open directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -88,16 +92,16 @@ class FileSystemService extends IpcService {
|
||||
if (parentStats?.isDirectory()) {
|
||||
const result = await shell.openPath(parentDirectory)
|
||||
if (result) {
|
||||
console.error('Failed to open parent directory:', result)
|
||||
scopedLoggers.system.error('Failed to open parent directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
console.error('File or directory does not exist:', normalizedPath)
|
||||
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to open file location:', error)
|
||||
scopedLoggers.system.error('Failed to open file location:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -122,7 +126,7 @@ class FileSystemService extends IpcService {
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -150,7 +154,7 @@ class FileSystemService extends IpcService {
|
||||
await shell.openExternal(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to open external URL:', error)
|
||||
scopedLoggers.system.error('Failed to open external URL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -166,7 +170,10 @@ class FileSystemService extends IpcService {
|
||||
])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
|
||||
scopedLoggers.system.error(
|
||||
'PowerShell clipboard copy failed, falling back to manual buffer:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||
@@ -194,7 +201,10 @@ class FileSystemService extends IpcService {
|
||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
|
||||
scopedLoggers.system.error(
|
||||
'osascript clipboard copy failed, falling back to manual buffer:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
const entries = [
|
||||
@@ -243,7 +253,7 @@ class FileSystemService extends IpcService {
|
||||
|
||||
return stats?.isFile() ?? false
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
scopedLoggers.system.error('Failed to check file existence:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -295,7 +305,7 @@ class FileSystemService extends IpcService {
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
scopedLoggers.system.error('Failed to delete file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
|
||||
return historyManager.removeHistoryItem(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
removeHistoryItems(_context: IpcContext, ids: string[]): number {
|
||||
return historyManager.removeHistoryItems(ids)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
|
||||
return historyManager.removeHistoryByPlaylistId(playlistId)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
clearHistory(_context: IpcContext): void {
|
||||
historyManager.clearHistory()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getHistoryCount(_context: IpcContext): {
|
||||
active: number
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
|
||||
class SettingsService extends IpcService {
|
||||
@@ -23,6 +25,14 @@ class SettingsService extends IpcService {
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'launchAtLogin') {
|
||||
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -41,12 +51,22 @@ class SettingsService extends IpcService {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
src/main/ipc/services/subscription-service.ts
Normal file
167
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } 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 defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||
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 || defaultDownloadDirectory,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
),
|
||||
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 }
|
||||
6
src/main/lib/database-path.ts
Normal file
6
src/main/lib/database-path.ts
Normal 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')
|
||||
}
|
||||
97
src/main/lib/database/migrate.ts
Normal file
97
src/main/lib/database/migrate.ts
Normal 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
|
||||
}
|
||||
83
src/main/lib/database/schema.ts
Normal file
83
src/main/lib/database/schema.ts
Normal 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
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||
import type {
|
||||
@@ -12,7 +13,11 @@ import type {
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} from '../../shared/types'
|
||||
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
|
||||
import {
|
||||
buildDownloadArgs,
|
||||
resolveVideoFormatSelector,
|
||||
sanitizeFilenameTemplate
|
||||
} from '../download-engine/args-builder'
|
||||
import {
|
||||
findFormatByIdCandidates,
|
||||
parseSizeToBytes,
|
||||
@@ -20,6 +25,7 @@ import {
|
||||
} from '../download-engine/format-utils'
|
||||
import { settingsManager } from '../settings'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
import { DownloadQueue } from './download-queue'
|
||||
import { ffmpegManager } from './ffmpeg-manager'
|
||||
import { historyManager } from './history-manager'
|
||||
@@ -30,6 +36,122 @@ interface DownloadProcess {
|
||||
process: YTDlpEventEmitter
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeFolderName = (value: string, fallback: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return fallback
|
||||
}
|
||||
const sanitized = trimmed
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[. ]+$/g, '')
|
||||
return sanitized || fallback
|
||||
}
|
||||
|
||||
const isLikelyChannelUrl = (url: string): boolean => {
|
||||
const normalized = url.toLowerCase()
|
||||
if (normalized.includes('list=')) {
|
||||
return false
|
||||
}
|
||||
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
|
||||
}
|
||||
|
||||
const resolveAutoPlaylistDownloadPath = (
|
||||
basePath: string,
|
||||
info: PlaylistInfo,
|
||||
url: string
|
||||
): string => {
|
||||
const kindFolder = isLikelyChannelUrl(url) ? 'Channels' : 'Playlists'
|
||||
const title = sanitizeFolderName(
|
||||
info.title || (kindFolder === 'Channels' ? 'Channel' : 'Playlist'),
|
||||
kindFolder === 'Channels' ? 'Channel' : 'Playlist'
|
||||
)
|
||||
return path.join(basePath, kindFolder, title)
|
||||
}
|
||||
|
||||
const resolveAutoVideoDownloadPath = (basePath: string, info?: VideoInfo): string => {
|
||||
const root = path.join(basePath, 'Videos')
|
||||
if (!info) {
|
||||
return root
|
||||
}
|
||||
const label = info.uploader?.trim() || info.title?.trim()
|
||||
if (!label) {
|
||||
return root
|
||||
}
|
||||
return path.join(root, sanitizeFolderName(label, 'Video'))
|
||||
}
|
||||
|
||||
const sanitizeTemplateValue = (value: string): string =>
|
||||
value
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '')
|
||||
|
||||
const resolveTemplateToken = (token: string, info?: VideoInfo): string | undefined => {
|
||||
if (!info) {
|
||||
return undefined
|
||||
}
|
||||
switch (token) {
|
||||
case 'uploader':
|
||||
return info.uploader
|
||||
case 'title':
|
||||
return info.title
|
||||
case 'id':
|
||||
return info.id
|
||||
case 'channel':
|
||||
return info.uploader
|
||||
case 'extractor':
|
||||
return info.extractor_key
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const resolveHistoryDownloadPath = (
|
||||
basePath: string,
|
||||
filenameTemplate?: string,
|
||||
info?: VideoInfo
|
||||
): string => {
|
||||
if (!filenameTemplate?.trim()) {
|
||||
return basePath
|
||||
}
|
||||
const safeTemplate = sanitizeFilenameTemplate(filenameTemplate)
|
||||
const resolvedTemplate = safeTemplate.replace(/%\(([^)]+)\)s/g, (match, token) => {
|
||||
const value = resolveTemplateToken(token, info)
|
||||
if (!value) {
|
||||
return match
|
||||
}
|
||||
return sanitizeTemplateValue(value)
|
||||
})
|
||||
const templateDir = path.posix.dirname(resolvedTemplate)
|
||||
if (templateDir === '.' || templateDir === '/') {
|
||||
return basePath
|
||||
}
|
||||
if (/%\([^)]+\)s/.test(templateDir)) {
|
||||
return basePath
|
||||
}
|
||||
return path.join(basePath, templateDir)
|
||||
}
|
||||
|
||||
const appendJsRuntimeArgs = (args: string[]): void => {
|
||||
const runtimeArgs = ytdlpManager.getJsRuntimeArgs()
|
||||
if (runtimeArgs.length > 0) {
|
||||
args.push(...runtimeArgs)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
@@ -74,10 +196,12 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -169,10 +293,12 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
type RawPlaylistEntry = {
|
||||
@@ -313,6 +439,10 @@ class DownloadEngine extends EventEmitter {
|
||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
||||
const settings = settingsManager.getAll()
|
||||
const resolvedDownloadPath =
|
||||
options.customDownloadPath?.trim() ||
|
||||
resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)
|
||||
ensureDirectoryExists(resolvedDownloadPath)
|
||||
|
||||
const selectedEntries = rawEntries.filter((entry) => {
|
||||
if (!entry.url) {
|
||||
@@ -336,7 +466,8 @@ class DownloadEngine extends EventEmitter {
|
||||
url: entry.url,
|
||||
type: options.type,
|
||||
format: options.format,
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||
customDownloadPath: resolvedDownloadPath
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
@@ -367,7 +498,7 @@ class DownloadEngine extends EventEmitter {
|
||||
title: entry.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
@@ -389,12 +520,20 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
startDownload(id: string, options: DownloadOptions): void {
|
||||
if (this.activeDownloads.has(id)) {
|
||||
console.warn(`Download ${id} is already active`)
|
||||
scopedLoggers.engine.warn(`Download ${id} is already active`)
|
||||
return
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
const settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||
targetDownloadPath,
|
||||
options.customFilenameTemplate
|
||||
)
|
||||
ensureDirectoryExists(targetDownloadPath)
|
||||
ensureDirectoryExists(historyDownloadPath)
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -402,7 +541,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
@@ -411,7 +553,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath
|
||||
downloadPath: historyDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,7 +564,8 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadPath = settings.downloadPath
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
@@ -430,9 +576,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let actualQuality: string | null = null
|
||||
let actualCodec: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -444,20 +589,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
if (selectedFormat) {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
|
||||
if (selectedFormat.height) {
|
||||
actualQuality = `${selectedFormat.height}p${
|
||||
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
|
||||
}`
|
||||
} else if (selectedFormat.format_note) {
|
||||
actualQuality = selectedFormat.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = selectedFormat.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
@@ -485,6 +616,19 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
|
||||
if (!options.customDownloadPath?.trim()) {
|
||||
resolvedDownloadPath = resolveAutoVideoDownloadPath(defaultDownloadPath, videoInfo)
|
||||
options.customDownloadPath = resolvedDownloadPath
|
||||
}
|
||||
|
||||
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||
resolvedDownloadPath,
|
||||
options.customFilenameTemplate,
|
||||
videoInfo
|
||||
)
|
||||
ensureDirectoryExists(historyDownloadPath)
|
||||
this.upsertHistoryEntry(id, options, { downloadPath: historyDownloadPath })
|
||||
|
||||
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
||||
if (!formatId) {
|
||||
return false
|
||||
@@ -502,18 +646,6 @@ class DownloadEngine extends EventEmitter {
|
||||
selectedFormat = candidate
|
||||
actualFormat = candidate.ext || actualFormat
|
||||
|
||||
if (candidate.height) {
|
||||
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
|
||||
} else if (candidate.format_note) {
|
||||
actualQuality = candidate.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = candidate.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
@@ -521,7 +653,44 @@ class DownloadEngine extends EventEmitter {
|
||||
return true
|
||||
}
|
||||
|
||||
const args = buildDownloadArgs(options, downloadPath, settings)
|
||||
const args = buildDownloadArgs(
|
||||
options,
|
||||
resolvedDownloadPath,
|
||||
settings,
|
||||
ytdlpManager.getJsRuntimeArgs()
|
||||
)
|
||||
|
||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||
if (!rawPath) {
|
||||
return
|
||||
}
|
||||
const trimmed = rawPath.trim().replace(/^"|"$/g, '')
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
lastKnownOutputPath = path.isAbsolute(trimmed)
|
||||
? trimmed
|
||||
: path.join(resolvedDownloadPath, trimmed)
|
||||
}
|
||||
|
||||
const extractOutputPathFromLog = (message: string): void => {
|
||||
const destinationMatch = message.match(/Destination:\s*(.+)$/)
|
||||
if (destinationMatch) {
|
||||
captureOutputPath(destinationMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/)
|
||||
if (mergingMatch) {
|
||||
captureOutputPath(mergingMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const movingMatch = message.match(/Moving file to\s+"(.+?)"/)
|
||||
if (movingMatch) {
|
||||
captureOutputPath(movingMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Check if format selector contains '+' which means video and audio will be merged
|
||||
const formatSelector =
|
||||
@@ -629,16 +798,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (extMatch && !actualFormat) {
|
||||
actualFormat = extMatch[1]
|
||||
}
|
||||
|
||||
const heightMatch = formatInfo.match(/(\d+)p/)
|
||||
if (heightMatch && !actualQuality) {
|
||||
actualQuality = `${heightMatch[1]}p`
|
||||
}
|
||||
|
||||
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
|
||||
if (codecMatch && !actualCodec) {
|
||||
actualCodec = codecMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +808,10 @@ class DownloadEngine extends EventEmitter {
|
||||
applySelectedFormat(formatMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
@@ -675,32 +838,44 @@ class DownloadEngine extends EventEmitter {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fileName = `${sanitizedTitle}.${extension}`
|
||||
const finalOutputPath = path.join(downloadPath, fileName)
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Generated file path for ID:',
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = finalOutputPath
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
// Try to find the actual file - yt-dlp may generate files with slightly different names
|
||||
const stats = await fs.stat(finalOutputPath)
|
||||
fileSize = stats.size
|
||||
actualFilePath = finalOutputPath
|
||||
} catch (error) {
|
||||
// If the expected file doesn't exist, try to find it by scanning the directory
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const files = await fs.readdir(downloadPath)
|
||||
// Look for files matching the title pattern with the correct extension
|
||||
let located = false
|
||||
for (const candidate of candidatePaths) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(candidate)
|
||||
fileSize = stats.size
|
||||
actualFilePath = candidate
|
||||
located = true
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!located) {
|
||||
const files = await fs.readdir(resolvedDownloadPath)
|
||||
const matchingFiles = files.filter((file) => {
|
||||
const baseName = file.replace(/\.[^.]+$/, '')
|
||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||
@@ -711,30 +886,33 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
// Use the most recently modified file if multiple matches
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(downloadPath, file)
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
return { file, path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
return { path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
})
|
||||
)
|
||||
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
||||
actualFilePath = mostRecent.path
|
||||
fileSize = mostRecent.size
|
||||
located = true
|
||||
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
||||
} else if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
if (!located) {
|
||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id, error)
|
||||
}
|
||||
} catch (scanError) {
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError)
|
||||
}
|
||||
} else if (!fileSize) {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id)
|
||||
}
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,13 +920,13 @@ class DownloadEngine extends EventEmitter {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
|
||||
const savedFileName = path.basename(actualFilePath)
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
this.emit('download-completed', id)
|
||||
@@ -834,15 +1012,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.fileSize !== undefined) {
|
||||
historyUpdates.fileSize = updates.fileSize
|
||||
}
|
||||
if (updates.format !== undefined) {
|
||||
historyUpdates.format = updates.format
|
||||
}
|
||||
if (updates.quality !== undefined) {
|
||||
historyUpdates.quality = updates.quality
|
||||
}
|
||||
if (updates.codec !== undefined) {
|
||||
historyUpdates.codec = updates.codec
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
@@ -870,6 +1039,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -879,8 +1051,8 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
@@ -907,14 +1079,13 @@ class DownloadEngine extends EventEmitter {
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
format: completedDownload?.item.format,
|
||||
quality: completedDownload?.item.quality,
|
||||
codec: completedDownload?.item.codec,
|
||||
description: completedDownload?.item.description,
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
viewCount: completedDownload?.item.viewCount,
|
||||
tags: completedDownload?.item.tags,
|
||||
origin: completedDownload?.item.origin,
|
||||
subscriptionId: completedDownload?.item.subscriptionId,
|
||||
playlistId: completedDownload?.item.playlistId,
|
||||
playlistTitle: completedDownload?.item.playlistTitle,
|
||||
playlistIndex: completedDownload?.item.playlistIndex,
|
||||
@@ -928,6 +1099,8 @@ class DownloadEngine extends EventEmitter {
|
||||
updates: Partial<DownloadHistoryItem>
|
||||
): void {
|
||||
const existing = historyManager.getHistoryById(id)
|
||||
const resolvedDownloadPath =
|
||||
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||
const base: DownloadHistoryItem = existing ?? {
|
||||
id,
|
||||
url: options.url,
|
||||
@@ -935,20 +1108,20 @@ class DownloadEngine extends EventEmitter {
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
format: updates.format,
|
||||
quality: updates.quality,
|
||||
codec: updates.codec,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
viewCount: updates.viewCount,
|
||||
tags: updates.tags,
|
||||
tags: updates.tags ?? options.tags,
|
||||
origin: updates.origin ?? options.origin,
|
||||
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
|
||||
// Download-specific format info
|
||||
selectedFormat: updates.selectedFormat,
|
||||
playlistId: updates.playlistId,
|
||||
@@ -965,7 +1138,11 @@ class DownloadEngine extends EventEmitter {
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
status: updates.status ?? base.status,
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
|
||||
downloadPath: resolvedDownloadPath ?? base.downloadPath,
|
||||
tags: updates.tags ?? base.tags,
|
||||
origin: updates.origin ?? base.origin,
|
||||
subscriptionId: updates.subscriptionId ?? base.subscriptionId
|
||||
}
|
||||
|
||||
historyManager.addHistoryItem(merged)
|
||||
|
||||
@@ -2,13 +2,14 @@ import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
class FfmpegManager {
|
||||
private ffmpegPath: string | null = null
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ffmpegPath = await this.findFfmpegBinary()
|
||||
console.log('ffmpeg initialized at:', this.ffmpegPath)
|
||||
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
@@ -30,7 +31,7 @@ class FfmpegManager {
|
||||
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)
|
||||
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
||||
return process.env.FFMPEG_PATH
|
||||
}
|
||||
|
||||
@@ -50,10 +51,13 @@ class FfmpegManager {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
console.warn('Failed to set executable permission on ffmpeg binary:', error)
|
||||
scopedLoggers.engine.warn(
|
||||
'Failed to set executable permission on ffmpeg binary:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log('Using bundled ffmpeg:', fullPath)
|
||||
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
@@ -62,7 +66,7 @@ class FfmpegManager {
|
||||
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)
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -72,7 +76,7 @@ class FfmpegManager {
|
||||
try {
|
||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
console.log('Using system ffmpeg:', systemPath)
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -84,7 +88,7 @@ class FfmpegManager {
|
||||
try {
|
||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
console.log('Using system ffmpeg:', output)
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
||||
return output
|
||||
}
|
||||
} catch (_error) {
|
||||
|
||||
@@ -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, inArray, 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,107 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
removeHistoryItems(ids: string[]): number {
|
||||
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
|
||||
if (uniqueIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let removedCount = 0
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(inArray(downloadHistoryTable.id, uniqueIds))
|
||||
.run()
|
||||
for (const id of uniqueIds) {
|
||||
if (this.history.delete(id)) {
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
removeHistoryByPlaylistId(playlistId: string): number {
|
||||
const normalized = playlistId.trim()
|
||||
if (!normalized) {
|
||||
return 0
|
||||
}
|
||||
let removedCount = 0
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.playlistId, normalized))
|
||||
.run()
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.playlistId === normalized) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
|
||||
return removedCount
|
||||
}
|
||||
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 +612,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()
|
||||
|
||||
643
src/main/lib/subscription-manager.ts
Normal file
643
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
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 ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const LegacyStore = ElectronStore.default || ElectronStore
|
||||
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()
|
||||
459
src/main/lib/subscription-scheduler.ts
Normal file
459
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
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 { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } 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() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
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()
|
||||
@@ -2,8 +2,9 @@ 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'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
|
||||
@@ -37,7 +38,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 +64,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,9 +80,9 @@ 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)
|
||||
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -132,6 +137,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()
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type YTDlpWrap from 'yt-dlp-wrap-plus'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
|
||||
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
||||
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
|
||||
class YtDlpManager {
|
||||
private ytdlpPath: string | null = null
|
||||
private ytdlpInstance: YTDlpWrapInstance | null = null
|
||||
private jsRuntimeArgs: string[] = []
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
||||
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
|
||||
console.log('yt-dlp initialized at:', this.ytdlpPath)
|
||||
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
|
||||
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
|
||||
}
|
||||
|
||||
getInstance(): YTDlpWrapInstance {
|
||||
@@ -33,6 +36,10 @@ class YtDlpManager {
|
||||
return this.ytdlpPath
|
||||
}
|
||||
|
||||
getJsRuntimeArgs(): string[] {
|
||||
return [...this.jsRuntimeArgs]
|
||||
}
|
||||
|
||||
private getResourcesPath(): string {
|
||||
// In development, read from project root's resources
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -57,7 +64,7 @@ class YtDlpManager {
|
||||
|
||||
// Check environment variable first
|
||||
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
||||
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
||||
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
||||
return process.env.YTDLP_PATH
|
||||
}
|
||||
|
||||
@@ -65,13 +72,13 @@ class YtDlpManager {
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
const bundledPath = path.join(resourcesPath, bundledName)
|
||||
if (fs.existsSync(bundledPath)) {
|
||||
console.log('Using bundled yt-dlp:', bundledPath)
|
||||
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
|
||||
// Make executable on Unix-like systems if needed
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(bundledPath, 0o755)
|
||||
} catch (error) {
|
||||
console.warn('Failed to set executable permission:', error)
|
||||
scopedLoggers.engine.warn('Failed to set executable permission:', error)
|
||||
}
|
||||
}
|
||||
return bundledPath
|
||||
@@ -82,7 +89,7 @@ class YtDlpManager {
|
||||
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
console.log('Using system yt-dlp:', p)
|
||||
scopedLoggers.engine.info('Using system yt-dlp:', p)
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -93,7 +100,7 @@ class YtDlpManager {
|
||||
try {
|
||||
const systemPath = execSync('which yt-dlp').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
console.log('Using system yt-dlp:', systemPath)
|
||||
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -115,6 +122,92 @@ class YtDlpManager {
|
||||
)
|
||||
}
|
||||
|
||||
private resolveJsRuntimeArgs(): string[] {
|
||||
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
|
||||
if (!runtime || runtime === 'none') {
|
||||
return []
|
||||
}
|
||||
|
||||
const runtimePath = this.resolveJsRuntimePath(runtime)
|
||||
if (runtimePath) {
|
||||
return ['--js-runtimes', `${runtime}:${runtimePath}`]
|
||||
}
|
||||
|
||||
if (process.env.YTDLP_JS_RUNTIME) {
|
||||
scopedLoggers.engine.warn(
|
||||
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
|
||||
)
|
||||
} else {
|
||||
scopedLoggers.engine.warn(
|
||||
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
|
||||
)
|
||||
}
|
||||
|
||||
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
|
||||
}
|
||||
|
||||
private resolveJsRuntimePath(runtime: string): string | null {
|
||||
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
|
||||
return envPath
|
||||
}
|
||||
|
||||
const platform = os.platform()
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
const resourceCandidates: string[] = []
|
||||
|
||||
if (runtime === 'deno') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
|
||||
} else if (runtime === 'node') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
|
||||
} else if (runtime === 'bun') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
} else if (runtime === 'quickjs') {
|
||||
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
|
||||
} else {
|
||||
resourceCandidates.push(runtime)
|
||||
if (platform === 'win32' && !runtime.endsWith('.exe')) {
|
||||
resourceCandidates.push(`${runtime}.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of resourceCandidates) {
|
||||
const fullPath = path.join(resourcesPath, candidate)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
|
||||
}
|
||||
}
|
||||
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
scopedLoggers.engine.info('Using system JS runtime:', output)
|
||||
return output
|
||||
}
|
||||
} else {
|
||||
const systemPath = execSync(`which ${runtime}`).toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Runtime not found in PATH
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Removed runtime download/update to avoid network dependency in production builds
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AppSettings } from '../shared/types'
|
||||
import { defaultSettings } from '../shared/types'
|
||||
import { scopedLoggers } from './utils/logger'
|
||||
|
||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||
const ElectronStore = require('electron-store')
|
||||
// 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) {
|
||||
scopedLoggers.system.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) {
|
||||
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
src/main/utils/auto-launch.ts
Normal file
38
src/main/utils/auto-launch.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
19
src/main/utils/path-helpers.ts
Normal file
19
src/main/utils/path-helpers.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -2,27 +2,151 @@ 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 { useAtom } from 'jotai'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { SupportedSites } from './pages/SupportedSites'
|
||||
import { settingsAtom } from './store/settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||
|
||||
const pageToPath: Record<Page, string> = {
|
||||
home: '/',
|
||||
subscriptions: '/subscriptions',
|
||||
settings: '/settings',
|
||||
about: '/about'
|
||||
}
|
||||
|
||||
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'
|
||||
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 autoUpdateEnabled = settings.autoUpdate
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const currentPage = pathToPage(location.pathname)
|
||||
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: Page) => {
|
||||
const targetPath = pageToPath[page] ?? '/'
|
||||
if (normalizePathname(location.pathname) !== targetPath) {
|
||||
navigate(targetPath)
|
||||
}
|
||||
},
|
||||
[location.pathname, navigate]
|
||||
)
|
||||
|
||||
const handleOpenSupportedSites = () => {
|
||||
window.open(supportedSitesUrl, '_blank')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = (rawUrl: unknown) => {
|
||||
const url = typeof rawUrl === 'string' ? rawUrl.trim() : ''
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
// Switch to home page to show download dialog
|
||||
handlePageChange('home')
|
||||
// The DownloadDialog component will handle opening the dialog and parsing the video
|
||||
}
|
||||
|
||||
ipcEvents.on('download:deeplink', handleDeepLink)
|
||||
return () => {
|
||||
ipcEvents.removeListener('download:deeplink', handleDeepLink)
|
||||
}
|
||||
}, [handlePageChange])
|
||||
|
||||
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
|
||||
@@ -44,63 +168,14 @@ function AppContent() {
|
||||
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 handleGoToDownloadPage = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open('https://vidbee.org/download/', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
if (autoUpdateEnabled) {
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
// No need to manually call downloadUpdate() because autoDownload is true
|
||||
} else {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
||||
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) => {
|
||||
@@ -119,13 +194,13 @@ function AppContent() {
|
||||
}
|
||||
|
||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
|
||||
const versionLabel = payload.version ?? ''
|
||||
const payload = (rawPayload ?? {}) as { version?: string }
|
||||
const versionLabel = payload?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
|
||||
toast.info(payload?.body ?? downloadedMessage, {
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
@@ -135,50 +210,29 @@ function AppContent() {
|
||||
})
|
||||
}
|
||||
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
// 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:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [autoUpdateEnabled, t])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
return <SupportedSites />
|
||||
default:
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||
<Sidebar
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
@@ -189,8 +243,22 @@ function AppContent() {
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
|
||||
{renderPage()}
|
||||
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
@@ -203,7 +271,9 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<AppContent />
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--radius: 0.625rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
@@ -89,21 +89,6 @@
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0;
|
||||
--shadow-color: #1da1f2;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -145,16 +130,10 @@
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
1385
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
1385
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,31 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@renderer/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Info,
|
||||
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,
|
||||
@@ -17,17 +34,136 @@ import {
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
// Helper function to generate file path with proper path separators
|
||||
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
|
||||
const fileName = `${title}.${format}`
|
||||
// Use proper path joining for cross-platform compatibility
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
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, '/')
|
||||
return `${normalizedDownloadPath}/${fileName}`
|
||||
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
|
||||
isSelected?: boolean
|
||||
onToggleSelect?: (id: string) => void
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
label: string
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
@@ -53,36 +189,60 @@ const formatDate = (timestamp?: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const formatDateShort = (timestamp?: number) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DownloadItem({ download, isSelected = false, onToggleSelect }: 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 actionsContainerClass =
|
||||
'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
const checkFileExists = async () => {
|
||||
if (!download.title || !download.downloadPath || !download.format) {
|
||||
if (!download.title || !download.downloadPath) {
|
||||
setFileExists(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = generateFilePath(download.downloadPath, download.title, download.format)
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
setFileExists(exists)
|
||||
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)
|
||||
@@ -90,7 +250,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [download.title, download.downloadPath, download.format])
|
||||
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -104,12 +264,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.openFileLocation(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.openFileLocation(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -120,7 +286,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return !!(download.title && download.downloadPath && download.format && fileExists)
|
||||
return Boolean(download.title && download.downloadPath && fileExists)
|
||||
}
|
||||
|
||||
// need title, downloadPath, format
|
||||
@@ -132,19 +298,26 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||
const downloadPath = download.downloadPath
|
||||
const format = download.format
|
||||
const format = resolvedExtension
|
||||
const title = download.title
|
||||
|
||||
if (!downloadPath || !format || !title) {
|
||||
if (!downloadPath || !title) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const filePath = generateFilePath(downloadPath, title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.copyFileToClipboard(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.copyFileToClipboard(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
@@ -160,16 +333,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
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
|
||||
await ipcServices.fs.deleteFile(filePath)
|
||||
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'))
|
||||
@@ -218,135 +400,377 @@ 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="relative z-20 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
|
||||
|
||||
const isSelectedHistory = selectionEnabled && isSelected
|
||||
|
||||
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">
|
||||
<div
|
||||
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
|
||||
isSelectedHistory ? 'bg-primary/10' : ''
|
||||
}`}
|
||||
>
|
||||
{isSelectedHistory && (
|
||||
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
|
||||
)}
|
||||
<div
|
||||
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
|
||||
selectionEnabled ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
{...(selectionEnabled
|
||||
? {
|
||||
onClick: () => onToggleSelect?.(download.id),
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggleSelect?.(download.id)
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': t('history.selectItem')
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
||||
<ImageWithPlaceholder
|
||||
src={thumbnailSrc}
|
||||
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
|
||||
{selectionEnabled && (
|
||||
<div
|
||||
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={Boolean(isSelected)}
|
||||
onCheckedChange={() => onToggleSelect?.(download.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={t('history.selectItem')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RemoteImage
|
||||
src={download.thumbnail}
|
||||
alt={download.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
fallbackIcon={<Play className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
<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">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
|
||||
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
|
||||
{download.title}
|
||||
</p>
|
||||
</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>
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{download.playlistId && (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
|
||||
>
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined && (
|
||||
<span className="ml-1 text-muted-foreground/80">
|
||||
{t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
<span className="truncate max-w-[120px] text-left">
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{/* Status */}
|
||||
{statusIcon && (
|
||||
<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>
|
||||
<div className="flex items-center shrink-0">{statusIcon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
<TooltipContent>
|
||||
<p>{statusText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
|
||||
{download.selectedFormat ? (
|
||||
)}
|
||||
{/* Timestamp */}
|
||||
{timestamp && (
|
||||
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
||||
)}
|
||||
{/* Quality */}
|
||||
{qualityLabel && (
|
||||
<>
|
||||
{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}
|
||||
{(statusIcon || timestamp) && (
|
||||
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||
)}
|
||||
<span className="shrink-0">{qualityLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{/* File size */}
|
||||
{inlineFileSize && (
|
||||
<>
|
||||
{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}
|
||||
{(statusIcon || timestamp || qualityLabel) && (
|
||||
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||
)}
|
||||
<span className="shrink-0">{inlineFileSize}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={actionsContainerClass}>
|
||||
<div className={`${actionsContainerClass} pointer-events-auto`}>
|
||||
{/* Info button - show details in sheet */}
|
||||
{hasMetadataDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSheetOpen(true)
|
||||
}}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('download.showDetails')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isHistory ? (
|
||||
<>
|
||||
{download.status === 'completed' && (
|
||||
@@ -356,8 +780,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -372,8 +799,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -389,8 +819,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleRemoveHistory}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveHistory()
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -409,8 +842,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -425,8 +861,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -443,8 +882,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCancel}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCancel()
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -456,13 +898,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
{/* Progress */}
|
||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1.5 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
|
||||
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
|
||||
<span className="font-medium shrink-0">
|
||||
{download.progress.percent.toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
|
||||
{download.progress.downloaded && download.progress.total && (
|
||||
<span className="truncate max-w-[100px]">
|
||||
{download.progress.downloaded} / {download.progress.total}
|
||||
@@ -487,6 +929,32 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Details Sheet */}
|
||||
{hasMetadataDetails && (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col p-0">
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle className="line-clamp-2">{download.title}</SheetTitle>
|
||||
<SheetDescription>{t('download.videoInfo')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{metadataDetails.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-sm text-foreground break-words">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { Button } from '../ui/button'
|
||||
import { Progress } from '../ui/progress'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
|
||||
interface PlaylistDownloadGroupProps {
|
||||
@@ -7,15 +11,50 @@ interface PlaylistDownloadGroupProps {
|
||||
title: string
|
||||
records: DownloadRecord[]
|
||||
totalCount: number
|
||||
selectedIds?: Set<string>
|
||||
onToggleSelect?: (id: string) => void
|
||||
onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'playlist_expanded_'
|
||||
|
||||
const getStorageKey = (groupId: string): string => {
|
||||
return `${STORAGE_KEY_PREFIX}${groupId}`
|
||||
}
|
||||
|
||||
const loadExpandedState = (groupId: string): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(groupId))
|
||||
return stored === 'true'
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist expanded state:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const saveExpandedState = (groupId: string, isExpanded: boolean): void => {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(groupId), String(isExpanded))
|
||||
} catch (error) {
|
||||
console.error('Failed to save playlist expanded state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function PlaylistDownloadGroup({
|
||||
groupId,
|
||||
title,
|
||||
records,
|
||||
totalCount
|
||||
totalCount,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onDeletePlaylist
|
||||
}: PlaylistDownloadGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId))
|
||||
|
||||
useEffect(() => {
|
||||
saveExpandedState(groupId, isExpanded)
|
||||
}, [groupId, isExpanded])
|
||||
|
||||
const completedCount = records.filter((record) => record.status === 'completed').length
|
||||
const errorCount = records.filter((record) => record.status === 'error').length
|
||||
@@ -24,35 +63,108 @@ export function PlaylistDownloadGroup({
|
||||
).length
|
||||
|
||||
const displayTitle = title || t('playlist.untitled')
|
||||
const historyRecords = records.filter((record) => record.entryType === 'history')
|
||||
const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist)
|
||||
const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand')
|
||||
const totalProgress = records.reduce((acc, record) => {
|
||||
if (record.status === 'completed') {
|
||||
return acc + 1
|
||||
}
|
||||
if (record.progress?.percent && record.progress.percent > 0) {
|
||||
return acc + Math.min(record.progress.percent, 100) / 100
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||
|
||||
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 className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate text-sm font-medium text-foreground">{displayTitle}</p>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>{t('playlist.groupActive', { count: activeCount })}</span>
|
||||
</>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-destructive">
|
||||
{t('playlist.groupErrors', { count: errorCount })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canDeletePlaylist && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeletePlaylist?.(
|
||||
groupId,
|
||||
displayTitle,
|
||||
historyRecords.map((record) => record.id)
|
||||
)
|
||||
}}
|
||||
aria-label={t('history.deletePlaylist')}
|
||||
title={t('history.deletePlaylist')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</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} />
|
||||
{!isExpanded && totalCount > 0 && (
|
||||
<Progress value={aggregatePercent} className="h-0.5 w-full" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr'
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="space-y-3 pt-1">
|
||||
{records.map((record) => (
|
||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||
<DownloadItem
|
||||
download={record}
|
||||
isSelected={selectedIds?.has(record.id) ?? false}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,147 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@renderer/components/ui/dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { History as HistoryIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import {
|
||||
downloadStatsAtom,
|
||||
downloadsArrayAtom,
|
||||
removeHistoryRecordsAtom,
|
||||
removeHistoryRecordsByPlaylistAtom
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
import { DownloadDialog } from './DownloadDialog'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||
type ConfirmAction =
|
||||
| { type: 'delete-selected'; ids: string[] }
|
||||
| { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }
|
||||
|
||||
export function UnifiedDownloadHistory() {
|
||||
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'
|
||||
}
|
||||
|
||||
interface UnifiedDownloadHistoryProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
}
|
||||
|
||||
export function UnifiedDownloadHistory({
|
||||
onOpenSupportedSites,
|
||||
onOpenSettings
|
||||
}: UnifiedDownloadHistoryProps) {
|
||||
const { t } = useTranslation()
|
||||
const allRecords = useAtomValue(downloadsArrayAtom)
|
||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
|
||||
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
|
||||
const [confirmBusy, setConfirmBusy] = useState(false)
|
||||
const [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false)
|
||||
const alsoDeleteFilesId = useId()
|
||||
|
||||
useHistorySync()
|
||||
|
||||
const historyRecords = useMemo(
|
||||
() => allRecords.filter((record) => record.entryType === 'history'),
|
||||
[allRecords]
|
||||
)
|
||||
const selectedCount = selectedIds.size
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return allRecords.filter((record) => {
|
||||
switch (statusFilter) {
|
||||
@@ -48,6 +169,184 @@ export function UnifiedDownloadHistory() {
|
||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||
]
|
||||
|
||||
const selectableIds = useMemo(
|
||||
() =>
|
||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||
[filteredRecords]
|
||||
)
|
||||
const selectableCount = selectableIds.length
|
||||
const selectionSummary =
|
||||
selectableCount === 0
|
||||
? t('history.selectedCount', { count: selectedCount })
|
||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
const historyIdSet = new Set(historyRecords.map((record) => record.id))
|
||||
setSelectedIds((prev) => {
|
||||
let changed = false
|
||||
const next = new Set<string>()
|
||||
for (const id of prev) {
|
||||
if (historyIdSet.has(id)) {
|
||||
next.add(id)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [historyRecords, selectedIds.size])
|
||||
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleRequestDeleteSelected = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) })
|
||||
}
|
||||
|
||||
const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'delete-playlist', playlistId, title, ids })
|
||||
}
|
||||
|
||||
const pruneSelectedIds = (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
let changed = false
|
||||
ids.forEach((id) => {
|
||||
if (next.delete(id)) {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
const confirmContent = useMemo(() => {
|
||||
if (!confirmAction) {
|
||||
return null
|
||||
}
|
||||
switch (confirmAction.type) {
|
||||
case 'delete-selected': {
|
||||
return {
|
||||
title: t('history.confirmDeleteSelectedTitle'),
|
||||
description: t('history.confirmDeleteSelectedDescription', {
|
||||
count: confirmAction.ids.length
|
||||
}),
|
||||
actionLabel: t('history.removeAction')
|
||||
}
|
||||
}
|
||||
case 'delete-playlist': {
|
||||
return {
|
||||
title: t('history.confirmDeletePlaylistTitle'),
|
||||
description: t('history.confirmDeletePlaylistDescription', {
|
||||
count: confirmAction.ids.length,
|
||||
title: confirmAction.title
|
||||
}),
|
||||
actionLabel: t('history.removeAction')
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [confirmAction, t])
|
||||
|
||||
const deleteHistoryFiles = async (records: DownloadRecord[]) => {
|
||||
const failedIds: string[] = []
|
||||
for (const record of records) {
|
||||
if (!record.title) {
|
||||
continue
|
||||
}
|
||||
const downloadPath = record.downloadPath || settings.downloadPath
|
||||
if (!downloadPath) {
|
||||
continue
|
||||
}
|
||||
const formatForPath = resolveDownloadExtension(record)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
record.title,
|
||||
formatForPath,
|
||||
record.savedFileName
|
||||
)
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
failedIds.push(record.id)
|
||||
}
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
console.warn('Failed to delete some playlist files:', failedIds)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!confirmAction) {
|
||||
return
|
||||
}
|
||||
setConfirmBusy(true)
|
||||
try {
|
||||
if (confirmAction.type === 'delete-selected') {
|
||||
await ipcServices.history.removeHistoryItems(confirmAction.ids)
|
||||
removeHistoryRecords(confirmAction.ids)
|
||||
if (alsoDeleteFiles) {
|
||||
const idSet = new Set(confirmAction.ids)
|
||||
const recordsToDelete = historyRecords.filter((record) => idSet.has(record.id))
|
||||
await deleteHistoryFiles(recordsToDelete)
|
||||
}
|
||||
pruneSelectedIds(confirmAction.ids)
|
||||
toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length }))
|
||||
}
|
||||
if (confirmAction.type === 'delete-playlist') {
|
||||
const idSet = new Set(confirmAction.ids)
|
||||
const playlistRecords = historyRecords.filter((record) => idSet.has(record.id))
|
||||
await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId)
|
||||
removeHistoryRecordsByPlaylist(confirmAction.playlistId)
|
||||
await deleteHistoryFiles(playlistRecords)
|
||||
pruneSelectedIds(confirmAction.ids)
|
||||
toast.success(
|
||||
t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length })
|
||||
)
|
||||
}
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
} catch (error) {
|
||||
if (confirmAction.type === 'delete-selected') {
|
||||
console.error('Failed to remove selected history items:', error)
|
||||
toast.error(t('notifications.itemsRemoveFailed'))
|
||||
}
|
||||
if (confirmAction.type === 'delete-playlist') {
|
||||
console.error('Failed to remove playlist history:', error)
|
||||
toast.error(t('notifications.playlistHistoryRemoveFailed'))
|
||||
}
|
||||
} finally {
|
||||
setConfirmBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedView = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
@@ -99,51 +398,61 @@ export function UnifiedDownloadHistory() {
|
||||
}, [filteredRecords])
|
||||
|
||||
return (
|
||||
<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
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
<span>{filter.label}</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'
|
||||
)}
|
||||
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
|
||||
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<span>{filter.label}</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>
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadDialog
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
|
||||
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<HistoryIcon className="h-10 w-10 opacity-50" />
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
{groupedView.order.map((item) => {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={`${item.record.entryType}:${item.record.id}`}
|
||||
download={item.record}
|
||||
isSelected={selectedIds.has(item.record.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -160,12 +469,90 @@ export function UnifiedDownloadHistory() {
|
||||
title={group.title}
|
||||
totalCount={group.totalCount}
|
||||
records={group.records}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onDeletePlaylist={handleRequestDeletePlaylist}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{selectedCount > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{selectionSummary}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3"
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
{t('history.clearSelection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3"
|
||||
onClick={handleRequestDeleteSelected}
|
||||
>
|
||||
{t('history.deleteSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={Boolean(confirmAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !confirmBusy) {
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmContent && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmContent.title}</DialogTitle>
|
||||
<DialogDescription>{confirmContent.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{confirmAction?.type === 'delete-selected' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={alsoDeleteFilesId}
|
||||
checked={alsoDeleteFiles}
|
||||
onCheckedChange={(checked) => setAlsoDeleteFiles(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={alsoDeleteFilesId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{t('history.alsoDeleteFiles')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmAction(null)
|
||||
setAlsoDeleteFiles(false)
|
||||
}}
|
||||
disabled={confirmBusy}
|
||||
>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmAction} disabled={confirmBusy}>
|
||||
{confirmContent.actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
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 { cn } from '@renderer/lib/utils'
|
||||
import { settingsAtom } from '@renderer/store/settings'
|
||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
||||
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, '/').replace(/\/{2,}/g, '/')
|
||||
|
||||
const buildDefaultSubscriptionDirectory = (downloadPath: string) => {
|
||||
const trimmed = downloadPath.trim().replace(/[\\/]+$/, '')
|
||||
if (!trimmed) {
|
||||
return 'Subscriptions'
|
||||
}
|
||||
return `${trimmed}/Subscriptions`
|
||||
}
|
||||
|
||||
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(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
const urlInputId = useId()
|
||||
const advancedOptionsId = useId()
|
||||
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||
|
||||
// Initialize form values based on mode
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setAdvancedOptionsOpen(false)
|
||||
|
||||
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(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)
|
||||
}
|
||||
}, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])
|
||||
|
||||
// Sync download directory with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)
|
||||
setDownloadDirectory((prev) => {
|
||||
if (!prev || prev === prevDefaultPathRef.current) {
|
||||
return newPath
|
||||
}
|
||||
return prev
|
||||
})
|
||||
prevDefaultPathRef.current = newPath
|
||||
}
|
||||
}, [settings.downloadPath, 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.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
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||
</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.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={advancedOptionsId}
|
||||
checked={advancedOptionsOpen}
|
||||
onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
|
||||
{t('advancedOptions.title')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{mode === 'add' && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,10 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
|
||||
className={cn('rounded-lg bg-muted/30 text-card-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,5 +22,4 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
||||
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
221
src/renderer/src/components/ui/context-menu.tsx
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
35
src/renderer/src/components/ui/hover-card.tsx
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'flex h-9 w-full rounded-md border px-3 py-1 text-base bg-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
123
src/renderer/src/components/ui/remote-image.tsx
Normal file
123
src/renderer/src/components/ui/remote-image.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
127
src/renderer/src/components/ui/sheet.tsx
Normal file
127
src/renderer/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/
|
||||
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'
|
||||
@@ -18,12 +19,25 @@ 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'
|
||||
type NavigationTarget = Page | 'supported-sites'
|
||||
|
||||
interface NavigationItem {
|
||||
id: NavigationTarget
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
inactive: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface PageNavigationItem {
|
||||
id: Page
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
@@ -35,9 +49,10 @@ interface NavigationItem {
|
||||
interface SidebarProps {
|
||||
currentPage: Page
|
||||
onPageChange: (page: Page) => void
|
||||
onOpenSupportedSites: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
|
||||
@@ -53,16 +68,25 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
label: t('menu.download')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
id: 'subscriptions',
|
||||
icon: {
|
||||
active: MingcuteRssFill,
|
||||
inactive: MingcuteRssLine
|
||||
},
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'supported-sites',
|
||||
icon: {
|
||||
active: MingcuteCheckCircleFill,
|
||||
inactive: MingcuteCheckCircleLine
|
||||
},
|
||||
label: t('menu.supportedSites')
|
||||
label: t('menu.supportedSites'),
|
||||
onClick: onOpenSupportedSites
|
||||
}
|
||||
]
|
||||
|
||||
const bottomNavigationItems: NavigationItem[] = [
|
||||
const bottomNavigationItems: PageNavigationItem[] = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: {
|
||||
@@ -96,26 +120,21 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
||||
const isActive = currentPage === item.id
|
||||
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||
const handleClick = item.onClick ?? (() => onPageChange(item.id as Page))
|
||||
|
||||
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={handleClick}
|
||||
className={`no-drag rounded-2xl 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}
|
||||
@@ -126,9 +145,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>
|
||||
@@ -148,7 +167,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 rounded-2xl w-12 h-12">
|
||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -192,7 +211,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 rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -4,15 +4,10 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@renderer/components/ui/accordion'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
interface AdvancedOptionsProps {
|
||||
startTime: string
|
||||
@@ -21,6 +16,7 @@ interface AdvancedOptionsProps {
|
||||
onStartTimeChange: (value: string) => void
|
||||
onEndTimeChange: (value: string) => void
|
||||
onDownloadSubsChange: (value: boolean) => void
|
||||
showAccordion?: boolean
|
||||
}
|
||||
|
||||
export function AdvancedOptions({
|
||||
@@ -29,71 +25,65 @@ export function AdvancedOptions({
|
||||
downloadSubs,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
onDownloadSubsChange
|
||||
onDownloadSubsChange,
|
||||
showAccordion = true
|
||||
}: AdvancedOptionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
const handleSelectLocation = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
await ipcServices.settings.set('downloadPath', path)
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error('Failed to select directory')
|
||||
}
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground ml-1">
|
||||
{t('advancedOptions.timeRange')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative group">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.startPlaceholder')}
|
||||
value={startTime}
|
||||
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||
className="h-9 text-center"
|
||||
title={t('advancedOptions.startHint')}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<div className="flex-1 relative group">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.endPlaceholder')}
|
||||
value={endTime}
|
||||
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||
className="h-9 text-center"
|
||||
title={t('advancedOptions.endHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-semibold">{t('advancedOptions.downloadSubs')}</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('advancedOptions.downloadSubsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!showAccordion) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('advancedOptions.timeRange')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.startPlaceholder')}
|
||||
value={startTime}
|
||||
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||
title={t('advancedOptions.startHint')}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('advancedOptions.endPlaceholder')}
|
||||
value={endTime}
|
||||
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||
title={t('advancedOptions.endHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('advancedOptions.startHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('advancedOptions.downloadSubs')}</Label>
|
||||
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
||||
</div>
|
||||
|
||||
{/* Download Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('advancedOptions.downloadLocation')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={settings.downloadPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectLocation} variant="outline">
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced" className="border-b">
|
||||
<AccordionTrigger className="flex items-center gap-2 py-4 text-sm font-semibold hover:no-underline">
|
||||
<span className="flex-1 text-left">{t('advancedOptions.title')}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-6 pt-2">{content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
@@ -8,19 +7,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
onExtract: (type: 'extract') => void
|
||||
export interface AudioExtractorState {
|
||||
extractFormat: string
|
||||
extractQuality: string
|
||||
}
|
||||
|
||||
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
state: AudioExtractorState
|
||||
onStateChange: (state: Partial<AudioExtractorState>) => void
|
||||
}
|
||||
|
||||
export function AudioExtractor({
|
||||
videoInfo: _videoInfo,
|
||||
state,
|
||||
onStateChange
|
||||
}: AudioExtractorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [extractFormat, setExtractFormat] = useState('mp3')
|
||||
const [extractQuality, setExtractQuality] = useState('5')
|
||||
const { extractFormat, extractQuality } = state
|
||||
|
||||
const audioFormats = [
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
@@ -49,7 +56,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
<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}>
|
||||
<Select
|
||||
value={extractFormat}
|
||||
onValueChange={(value) => onStateChange({ extractFormat: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -65,7 +75,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<Select
|
||||
value={extractQuality}
|
||||
onValueChange={(value) => onStateChange({ extractQuality: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -79,10 +92,6 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
|
||||
{t('audioExtract.extract')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -73,23 +73,31 @@ export function FormatSelector({
|
||||
useEffect(() => {
|
||||
// Filter and sort formats
|
||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||
const videos = formats.filter(
|
||||
(f) =>
|
||||
f.video_ext !== 'none' &&
|
||||
f.vcodec &&
|
||||
f.vcodec !== 'none' &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
const isVideoFormat = (format: VideoFormat) =>
|
||||
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||
const isAudioFormat = (format: VideoFormat) =>
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' || !format.video_ext)
|
||||
const isHlsFormat = (format: VideoFormat) =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
|
||||
const videoCandidates = formats.filter(
|
||||
(format) => isVideoFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
const audios = formats.filter(
|
||||
(f) =>
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext) &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
const audioCandidates = formats.filter(
|
||||
(format) => isAudioFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
|
||||
const videos =
|
||||
videoCandidates.length > 0
|
||||
? videoCandidates
|
||||
: formats.filter((format) => isVideoFormat(format))
|
||||
const audios =
|
||||
audioCandidates.length > 0
|
||||
? audioCandidates
|
||||
: formats.filter((format) => isAudioFormat(format))
|
||||
|
||||
// Apply showMoreFormats filter
|
||||
const filteredVideos = settings.showMoreFormats
|
||||
? videos
|
||||
@@ -99,6 +107,9 @@ export function FormatSelector({
|
||||
? audios
|
||||
: audios.filter((f) => f.ext !== 'webm')
|
||||
|
||||
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
|
||||
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
|
||||
|
||||
// Sort formats by quality (best first)
|
||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by height (higher is better)
|
||||
@@ -138,23 +149,23 @@ export function FormatSelector({
|
||||
return 0
|
||||
}
|
||||
|
||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
||||
finalVideos.sort(sortVideoFormatsByQuality)
|
||||
finalAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(filteredVideos)
|
||||
setAudioFormats(filteredAudios)
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
// Auto-select best format based on preferences
|
||||
if (filteredVideos.length > 0 && !selectedVideo) {
|
||||
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
|
||||
if (finalVideos.length > 0 && !selectedVideo) {
|
||||
const preferred = pickVideoFormatForPreset(finalVideos, settings.oneClickQuality)
|
||||
if (preferred) {
|
||||
setSelectedVideo(preferred.format_id)
|
||||
onVideoFormatChange?.(preferred.format_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredAudios.length > 0 && !selectedAudio) {
|
||||
const best = filteredAudios[0]
|
||||
if (finalAudios.length > 0 && !selectedAudio) {
|
||||
const best = finalAudios[0]
|
||||
setSelectedAudio(best.format_id)
|
||||
onAudioFormatChange?.(best.format_id)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { Clock, Eye, Play } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { addDownloadAtom } from '../../store/downloads'
|
||||
import { clearVideoInfoAtom } from '../../store/video'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
import { AudioExtractor } from './AudioExtractor'
|
||||
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
|
||||
import { FormatSelector } from './FormatSelector'
|
||||
|
||||
export interface VideoInfoCardState {
|
||||
title: string
|
||||
activeTab: 'video' | 'audio'
|
||||
selectedVideoFormat: string
|
||||
selectedAudioForVideo: string
|
||||
selectedAudioFormat: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
downloadSubs: boolean
|
||||
customDownloadPath: string
|
||||
audioExtractor: AudioExtractorState
|
||||
}
|
||||
|
||||
interface VideoInfoCardProps {
|
||||
videoInfo: VideoInfo
|
||||
state: VideoInfoCardState
|
||||
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||
onTabChange: (tab: 'video' | 'audio') => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
@@ -46,129 +47,62 @@ function formatViews(views?: number): string {
|
||||
return views.toString()
|
||||
}
|
||||
|
||||
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
export function VideoInfoCard({
|
||||
videoInfo,
|
||||
state,
|
||||
onStateChange,
|
||||
onTabChange
|
||||
}: VideoInfoCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const titleId = useId()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
|
||||
const [title, setTitle] = useState(videoInfo.title)
|
||||
const [selectedVideoFormat, setSelectedVideoFormat] = useState('')
|
||||
const [selectedAudioForVideo, setSelectedAudioForVideo] = useState('')
|
||||
const [selectedAudioFormat, setSelectedAudioFormat] = useState('')
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [downloadSubs, setDownloadSubs] = useState(false)
|
||||
|
||||
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
|
||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: videoInfo.webpage_url || '',
|
||||
title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
type: type === 'extract' ? 'audio' : type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
const options = {
|
||||
url: videoInfo.webpage_url || '',
|
||||
type,
|
||||
format: type === 'video' ? selectedVideoFormat : selectedAudioFormat,
|
||||
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
|
||||
startTime: startTime || undefined,
|
||||
endTime: endTime || undefined,
|
||||
downloadSubs
|
||||
}
|
||||
|
||||
addDownload(downloadItem)
|
||||
|
||||
try {
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
// Update the download info in the main process queue
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
toast.success(t('notifications.downloadStarted'))
|
||||
clearVideoInfo()
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error)
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
}
|
||||
const { title, activeTab } = state
|
||||
|
||||
return (
|
||||
<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 className="overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div>
|
||||
<Card className="overflow-hidden border shadow-sm">
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
|
||||
fallbackIcon={<Play className="h-12 w-12" />}
|
||||
/>
|
||||
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 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.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.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>
|
||||
)}
|
||||
</CardDescription>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
|
||||
>
|
||||
{videoInfo.extractor_key || t('download.videoInfo')}
|
||||
</Badge>
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-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 h-10"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
|
||||
{videoInfo.uploader && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,72 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="video" className="text-sm font-medium">
|
||||
<CardContent className="p-3 pt-3">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
onTabChange(v as 'video' | 'audio')
|
||||
onStateChange({ activeTab: v as 'video' | 'audio' })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
|
||||
<TabsTrigger value="video" className="text-xs">
|
||||
{t('download.video')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-sm font-medium">
|
||||
<TabsTrigger value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="video" className="space-y-5 mt-0">
|
||||
<TabsContent value="video" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="video"
|
||||
onVideoFormatChange={setSelectedVideoFormat}
|
||||
onAudioFormatChange={setSelectedAudioForVideo}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
|
||||
/>
|
||||
|
||||
<AdvancedOptions
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
downloadSubs={downloadSubs}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<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-5 mt-0">
|
||||
<TabsContent value="audio" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
onAudioFormatChange={setSelectedAudioFormat}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
|
||||
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
|
||||
|
||||
<AdvancedOptions
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
downloadSubs={downloadSubs}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
<AudioExtractor
|
||||
videoInfo={videoInfo}
|
||||
state={state.audioExtractor}
|
||||
onStateChange={(updates) =>
|
||||
onStateChange({
|
||||
audioExtractor: { ...state.audioExtractor, ...updates }
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDownload('audio')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
export interface PopularSite {
|
||||
id: string
|
||||
url: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
export const popularSites: PopularSite[] = [
|
||||
{ id: 'youtube' },
|
||||
{ id: 'youtubemusic' },
|
||||
{ id: 'tiktok' },
|
||||
{ id: 'facebook' },
|
||||
{ id: 'instagram' },
|
||||
{ id: 'twitter' },
|
||||
{ id: 'soundcloud' },
|
||||
{ id: 'reddit' },
|
||||
{ id: 'vimeo' },
|
||||
{ id: 'dailymotion' },
|
||||
{ id: 'twitch' },
|
||||
{ id: 'linkedin' },
|
||||
{ id: 'pinterest' },
|
||||
{ id: 'tumblr' },
|
||||
{ id: 'mixcloud' },
|
||||
{ id: 'niconico' },
|
||||
{ id: 'kick' },
|
||||
{ id: 'bandcamp' }
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
586
src/renderer/src/locales/ar.json
Normal file
586
src/renderer/src/locales/ar.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"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": "اختر تنسيق الفيديو",
|
||||
"startDownload": "بدء التحميل",
|
||||
"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": "عرض جميع المواقع المدعومة"
|
||||
}
|
||||
}
|
||||
586
src/renderer/src/locales/de.json
Normal file
586
src/renderer/src/locales/de.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"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",
|
||||
"startDownload": "Download starten",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"autoUpdateTitle": "Auto updates",
|
||||
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
||||
"betaProgramTitle": "Preview channel",
|
||||
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
|
||||
"description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Follow @nexmoex"
|
||||
},
|
||||
@@ -51,6 +51,10 @@
|
||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||
"feedback": "Feedback & issues",
|
||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
||||
"githubIssues": "GitHub Issues",
|
||||
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||
"discord": "Discord Community",
|
||||
"discordDescription": "Join our Discord community for discussions and support.",
|
||||
"license": "License",
|
||||
"licenseDescription": "Review the open-source license terms.",
|
||||
"website": "Official website",
|
||||
@@ -75,7 +79,8 @@
|
||||
"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",
|
||||
@@ -122,6 +127,10 @@
|
||||
"downloadBtn": "Download",
|
||||
"downloadPending": "Pending",
|
||||
"downloadQueue": "Download Queue",
|
||||
"customDownloadFolder": "Custom download folder",
|
||||
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
|
||||
"autoFolderHint": "Automatic folders are created from metadata.",
|
||||
"useAutoFolder": "Use automatic folder",
|
||||
"downloadVideo": "Download Video",
|
||||
"downloading": "Downloading...",
|
||||
"enterUrl": "Enter Video URL",
|
||||
@@ -151,8 +160,12 @@
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectDownloadType": "Select download type",
|
||||
"selectFormat": "Select Format",
|
||||
"startDownload": "Start Download",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
"singleVideo": "Single Video",
|
||||
"speed": "Speed",
|
||||
@@ -163,7 +176,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",
|
||||
@@ -180,10 +219,23 @@
|
||||
"clearCancelled": "Clear Cancelled",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"clearErrors": "Clear Errors",
|
||||
"clearAll": "Clear All History",
|
||||
"clearAllAction": "Clear History",
|
||||
"clearSelection": "Clear Selection",
|
||||
"confirmClearAllTitle": "Clear all history?",
|
||||
"confirmClearAllDescription": "Remove {{count}} items from your history. Files stay on disk.",
|
||||
"confirmDeleteSelectedTitle": "Remove selected items?",
|
||||
"confirmDeleteSelectedDescription": "Remove {{count}} items from your history. Files stay on disk.",
|
||||
"alsoDeleteFiles": "Also delete files",
|
||||
"confirmDeletePlaylistTitle": "Remove playlist history?",
|
||||
"confirmDeletePlaylistDescription": "Remove {{count}} items from {{title}} and delete their files.",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyUrl": "Copy URL",
|
||||
"date": "Date",
|
||||
"deletePlaylist": "Remove Playlist",
|
||||
"deleteSelected": "Remove Selected",
|
||||
"description": "View and manage your download history",
|
||||
"doneSelecting": "Done",
|
||||
"duration": "Duration",
|
||||
"fileSize": "File Size",
|
||||
"filters": {
|
||||
@@ -199,7 +251,14 @@
|
||||
"openFileLocation": "Open File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeAction": "Remove",
|
||||
"removeItem": "Remove Item",
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"selectVisible": "Select visible",
|
||||
"selectItem": "Select item",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectionSummary": "{{selected}} of {{total}} visible selected",
|
||||
"stats": {
|
||||
"cancelled": "Cancelled",
|
||||
"completed": "Completed",
|
||||
@@ -217,6 +276,8 @@
|
||||
"about": "About",
|
||||
"download": "Download",
|
||||
"playlist": "Download Playlist",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Subscriptions",
|
||||
"preferences": "Preferences",
|
||||
"supportedSites": "Supported Sites",
|
||||
"theme": "Theme:"
|
||||
@@ -226,9 +287,15 @@
|
||||
"downloadCompleted": "Download completed",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"historyCleared": "History cleared",
|
||||
"historyClearFailed": "Failed to clear history",
|
||||
"itemRemoved": "Item removed",
|
||||
"itemsRemoved": "Removed {{count}} items",
|
||||
"itemsRemoveFailed": "Failed to remove selected items",
|
||||
"openFileFailed": "Failed to open file",
|
||||
"openFolderFailed": "Failed to open folder",
|
||||
"playlistHistoryRemoved": "Playlist removed and files deleted",
|
||||
"playlistHistoryRemoveFailed": "Failed to remove playlist history",
|
||||
"removeFailed": "Failed to remove item",
|
||||
"settingsSaved": "Settings saved",
|
||||
"urlCopied": "URL copied to clipboard",
|
||||
@@ -237,6 +304,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"collapsedProgress": "Downloading playlist: {{completed}} / {{total}} completed",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -252,7 +320,9 @@
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupCollapse": "Collapse",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupExpand": "Expand",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"linkLabel": "Playlist URL",
|
||||
"noEntries": "No videos were found in this playlist",
|
||||
@@ -267,6 +337,8 @@
|
||||
"range": "Range (Optional)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}} selected",
|
||||
"downloadCurrentRange": "Download Selected",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist",
|
||||
@@ -291,12 +363,14 @@
|
||||
"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",
|
||||
@@ -308,6 +382,11 @@
|
||||
"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",
|
||||
@@ -332,6 +411,10 @@
|
||||
"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",
|
||||
@@ -342,6 +425,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",
|
||||
"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",
|
||||
"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.",
|
||||
|
||||
586
src/renderer/src/locales/es.json
Normal file
586
src/renderer/src/locales/es.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"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",
|
||||
"startDownload": "Iniciar descarga",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"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"
|
||||
@@ -14,6 +16,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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.",
|
||||
@@ -62,13 +76,7 @@
|
||||
"sourceCode": "Le code source est disponible",
|
||||
"title": "À propos",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Dernière : v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nouvelle version disponible",
|
||||
"uptodate": "Vous êtes à jour",
|
||||
"error": "Impossible de récupérer la dernière version"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fermer l'application quand le téléchargement se termine",
|
||||
@@ -122,11 +130,39 @@
|
||||
"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",
|
||||
@@ -134,6 +170,7 @@
|
||||
"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",
|
||||
@@ -145,6 +182,8 @@
|
||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectVideoFormat": "Sélectionner le Format Vidéo",
|
||||
"startDownload": "Démarrer le téléchargement",
|
||||
"showDetails": "Afficher les détails",
|
||||
"singleVideo": "Vidéo Unique",
|
||||
"speed": "Vitesse",
|
||||
"title": "Titre",
|
||||
@@ -209,6 +248,8 @@
|
||||
"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 :"
|
||||
},
|
||||
@@ -226,6 +267,8 @@
|
||||
"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",
|
||||
@@ -240,12 +283,27 @@
|
||||
"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"
|
||||
"title": "Télécharger la Playlist",
|
||||
"totalVideos": "Nombre total de vidéos : {{count}}",
|
||||
"untitled": "Liste de lecture sans titre"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "À propos",
|
||||
@@ -261,16 +319,31 @@
|
||||
"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",
|
||||
@@ -289,6 +362,7 @@
|
||||
"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",
|
||||
@@ -296,6 +370,10 @@
|
||||
"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é lorsqu’un 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",
|
||||
@@ -390,5 +468,119 @@
|
||||
},
|
||||
"popularSection": "Plateformes principales",
|
||||
"viewAll": "Voir tous les sites supportés"
|
||||
},
|
||||
"subscriptions": {
|
||||
"actions": {
|
||||
"add": "Ajouter",
|
||||
"disable": "Désactiver",
|
||||
"edit": "Modifier",
|
||||
"enable": "Activer",
|
||||
"refresh": "Rafraîchir",
|
||||
"remove": "Retirer",
|
||||
"save": "Enregistrer les modifications",
|
||||
"selectDirectory": "Parcourir"
|
||||
},
|
||||
"add": {
|
||||
"description": "Collez un lien de flux RSS. \nVidBee détectera automatiquement le flux.",
|
||||
"title": "Ajouter un flux RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "Intervalle de vérification (heures)",
|
||||
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
|
||||
"downloadDirectory": "Répertoire de téléchargement",
|
||||
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
|
||||
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
|
||||
"onlyLatestDescription": "Lorsqu'il est activé, VidBee ignore les anciens éléments du backlog et récupère uniquement le téléchargement le plus récent.",
|
||||
"title": "Paramètres par défaut de l'automatisation"
|
||||
},
|
||||
"description": "Surveillez automatiquement les flux RSS et mettez les nouveaux téléchargements en file d’attente sans travail manuel.",
|
||||
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
|
||||
"detecting": "Détection du flux...",
|
||||
"edit": {
|
||||
"description": "Ajustez les filtres, les balises et les remplacements pour ce flux.",
|
||||
"title": "Modifier {{name}}"
|
||||
},
|
||||
"empty": "Aucun abonnement pour l'instant. \nAjoutez vos chaînes préférées pour lancer le téléchargement automatique.",
|
||||
"fields": {
|
||||
"customDirectory": "Répertoire personnalisé",
|
||||
"disabled": "Désactivé",
|
||||
"enabled": "Activé",
|
||||
"keywords": "Filtre de mots clés (séparés par des virgules)",
|
||||
"namingTemplate": "Modèle de nom de fichier personnalisé (fichier uniquement)",
|
||||
"onlyLatest": "Téléchargez uniquement la dernière vidéo",
|
||||
"onlyLatestDescription": "Ignorez les éléments du backlog et récupérez uniquement le téléchargement le plus récent à partir de ce flux.",
|
||||
"onlyLatestShort": "Seulement le dernier",
|
||||
"tags": "Balises automatiques",
|
||||
"url": "URL du flux"
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
"open": "Ouvrir dans le navigateur",
|
||||
"queue": "Ajouter à la file d'attente de téléchargement"
|
||||
},
|
||||
"count": "{{count}} articles",
|
||||
"empty": "Aucun élément de flux récent trouvé.",
|
||||
"fromChannel": "De {{channel}}",
|
||||
"status": {
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Complété",
|
||||
"downloading": "Téléchargement",
|
||||
"error": "Échoué",
|
||||
"notQueued": "Pas en file d'attente",
|
||||
"pending": "En attente",
|
||||
"processing": "Traitement",
|
||||
"queued": "En file d'attente"
|
||||
},
|
||||
"title": "Derniers téléchargements ({{count}})",
|
||||
"tooltip": {
|
||||
"downloadPending": "En attente des détails du téléchargement...",
|
||||
"downloadStatus": "État du téléchargement : {{status}}",
|
||||
"notQueued": "Pas encore dans la file d'attente de téléchargement"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"noThumbnail": "Aucune vignette",
|
||||
"subscription": "Abonnement",
|
||||
"unknown": "Abonnement inconnu"
|
||||
},
|
||||
"lastChecked": "Dernière vérification : {{time}}",
|
||||
"latestVideo": "Dernière vidéo : {{title}}",
|
||||
"never": "Jamais",
|
||||
"notifications": {
|
||||
"createError": "Échec de l'ajout de l'abonnement.",
|
||||
"created": "Abonnement ajouté",
|
||||
"directoryError": "Échec de l'ouverture du sélecteur de répertoire.",
|
||||
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
|
||||
"itemQueued": "Ajouté à la file d'attente de téléchargement",
|
||||
"missingUrl": "Veuillez d'abord coller un lien de chaîne.",
|
||||
"openLinkError": "Échec de l'ouverture du lien vidéo.",
|
||||
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
|
||||
"refreshStarted": "Actualisation démarrée",
|
||||
"removed": "Abonnement supprimé",
|
||||
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
|
||||
"updated": "Abonnement mis à jour"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"rssHub": {
|
||||
"description": "Combinez VidBee avec RSSHub pour activer les abonnements et les téléchargements automatisés à partir de diverses plateformes. \nUne fois configuré, VidBee s'exécute en arrière-plan et télécharge automatiquement les dernières vidéos et contenus.",
|
||||
"hint": "Vous n'avez pas d'URL de flux RSS ? \nUtilisez RSSHub pour générer des flux RSS pour YouTube, Twitter et des milliers d'autres plateformes.",
|
||||
"learnMore": "En savoir plus sur RSSHub",
|
||||
"openDocs": "Ouvrir la documentation RSSHub",
|
||||
"title": "Abonnements automatisés avec RSSHub"
|
||||
},
|
||||
"status": {
|
||||
"checking": "Vérification",
|
||||
"failed": "Échoué",
|
||||
"idle": "Inactif",
|
||||
"title": "Statut",
|
||||
"tooltip": {
|
||||
"updatedAt": "Mise à jour : {{time}}"
|
||||
},
|
||||
"up-to-date": "À jour"
|
||||
},
|
||||
"subtitle": "{{count}} abonnement{{count, plural, one {} other {s}}}",
|
||||
"title": "Abonnements"
|
||||
}
|
||||
}
|
||||
|
||||
586
src/renderer/src/locales/id.json
Normal file
586
src/renderer/src/locales/id.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"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",
|
||||
"startDownload": "Mulai unduhan",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"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"
|
||||
@@ -14,6 +16,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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.",
|
||||
@@ -62,13 +76,7 @@
|
||||
"sourceCode": "Il codice sorgente è disponibile",
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Ultima: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nuova versione disponibile",
|
||||
"uptodate": "Sei aggiornato",
|
||||
"error": "Impossibile recuperare l'ultima versione"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Chiudi app quando il download finisce",
|
||||
@@ -122,11 +130,39 @@
|
||||
"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",
|
||||
@@ -134,6 +170,7 @@
|
||||
"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",
|
||||
@@ -145,6 +182,8 @@
|
||||
"selectAudioFormat": "Seleziona Formato Audio",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectVideoFormat": "Seleziona Formato Video",
|
||||
"startDownload": "Avvia download",
|
||||
"showDetails": "Mostra dettagli",
|
||||
"singleVideo": "Video Singolo",
|
||||
"speed": "Velocità",
|
||||
"title": "Titolo",
|
||||
@@ -209,6 +248,8 @@
|
||||
"download": "Scarica",
|
||||
"playlist": "Scarica Playlist",
|
||||
"preferences": "Preferenze",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Abbonamenti",
|
||||
"supportedSites": "Siti Supportati",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
@@ -226,6 +267,8 @@
|
||||
"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",
|
||||
@@ -240,12 +283,27 @@
|
||||
"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"
|
||||
"title": "Scarica Playlist",
|
||||
"totalVideos": "Video totali: {{count}}",
|
||||
"untitled": "Playlist senza titolo"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Informazioni",
|
||||
@@ -261,16 +319,31 @@
|
||||
"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",
|
||||
@@ -289,6 +362,7 @@
|
||||
"normal": "Normale",
|
||||
"worst": "Peggiore"
|
||||
},
|
||||
"openLinkError": "Impossibile aprire il collegamento",
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Server proxy per le richieste di rete",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -296,6 +370,10 @@
|
||||
"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",
|
||||
@@ -390,5 +468,119 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "アップデートを確認",
|
||||
"download": "ダウンロード",
|
||||
"email": "メール",
|
||||
"feedback": "フィードバック",
|
||||
"goToDownload": "ダウンロードページへ行く",
|
||||
"openRepo": "GitHubリポジトリを開く",
|
||||
"view": "表示",
|
||||
"visit": "訪問"
|
||||
@@ -14,6 +16,7 @@
|
||||
"betaProgramDescription": "他の人より先に早期ビルドと今後の機能を受け取ります。",
|
||||
"betaProgramTitle": "プレビューチャンネル",
|
||||
"description": "VidBeeはElectronで構築され、yt-dlpによって動力を得る無料のオープンソースダウンローダーです。",
|
||||
"downloadingUpdate": "アップデートをダウンロードしています",
|
||||
"followAuthorActions": {
|
||||
"follow": "@nexmoexをフォロー"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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": "このページを離れることなくアップデート設定を調整します。",
|
||||
@@ -62,13 +76,7 @@
|
||||
"sourceCode": "ソースコードが利用可能",
|
||||
"title": "について",
|
||||
"version": "バージョン",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "新しいバージョンが利用可能",
|
||||
"uptodate": "最新バージョンを使用中",
|
||||
"error": "最新バージョンを取得できません"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "ダウンロード完了時にアプリを閉じる",
|
||||
@@ -122,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": "ソースURL",
|
||||
"videoCodec": "ビデオコーデック",
|
||||
"views": "ビュー",
|
||||
"width": "幅"
|
||||
},
|
||||
"moreOptions": "その他のオプション",
|
||||
"noActiveDownloads": "アクティブなダウンロードなし",
|
||||
"noAudio": "オーディオなし",
|
||||
@@ -134,6 +170,7 @@
|
||||
"noItems": "アイテムが見つかりません",
|
||||
"oneClickDownload": "ワンクリックダウンロード",
|
||||
"oneClickDownloadDescription": "確認なしでデフォルト設定で直接ダウンロード",
|
||||
"oneClickDownloadEnabled": "ワンクリックダウンロードが有効になります。\nダウンロードはデフォルト設定で直接開始されます。",
|
||||
"oneClickDownloadNow": "今すぐダウンロード",
|
||||
"oneClickDownloadStarted": "デフォルト設定でダウンロード開始",
|
||||
"paste": "貼り付け",
|
||||
@@ -145,6 +182,8 @@
|
||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||
"selectFormat": "フォーマットを選択",
|
||||
"selectVideoFormat": "ビデオフォーマットを選択",
|
||||
"startDownload": "ダウンロードを開始",
|
||||
"showDetails": "詳細を表示",
|
||||
"singleVideo": "単一ビデオ",
|
||||
"speed": "速度",
|
||||
"title": "タイトル",
|
||||
@@ -209,6 +248,8 @@
|
||||
"download": "ダウンロード",
|
||||
"playlist": "プレイリストをダウンロード",
|
||||
"preferences": "設定",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "定期購入",
|
||||
"supportedSites": "サポートされているサイト",
|
||||
"theme": "テーマ:"
|
||||
},
|
||||
@@ -226,6 +267,8 @@
|
||||
"videoCopied": "ビデオがクリップボードにコピーされました"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "プレイリスト",
|
||||
"clearPreview": "プレビューをクリアする",
|
||||
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
|
||||
"completed": "プレイリストがダウンロードされました",
|
||||
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
||||
@@ -240,12 +283,27 @@
|
||||
"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": "プレイリストをダウンロード"
|
||||
"title": "プレイリストをダウンロード",
|
||||
"totalVideos": "合計動画: {{count}}",
|
||||
"untitled": "無題のプレイリスト"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "について",
|
||||
@@ -261,16 +319,31 @@
|
||||
"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": "最大同時ダウンロード数",
|
||||
@@ -289,6 +362,7 @@
|
||||
"normal": "通常",
|
||||
"worst": "最悪"
|
||||
},
|
||||
"openLinkError": "リンクを開けませんでした",
|
||||
"proxy": "プロキシ",
|
||||
"proxyDescription": "ネットワークリクエスト用のプロキシサーバー",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -296,6 +370,10 @@
|
||||
"selectPath": "選択",
|
||||
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
||||
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
|
||||
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 ~ 24 時間)。"
|
||||
},
|
||||
"system": "システム",
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "VidBeeのライト、ダーク、またはシステムテーマを選択",
|
||||
@@ -390,5 +468,119 @@
|
||||
},
|
||||
"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": "定期購入"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"download": "다운로드",
|
||||
"email": "이메일",
|
||||
"feedback": "피드백",
|
||||
"goToDownload": "다운로드 페이지로 이동",
|
||||
"openRepo": "GitHub 저장소 열기",
|
||||
"view": "보기",
|
||||
"visit": "방문"
|
||||
@@ -14,6 +16,7 @@
|
||||
"betaProgramDescription": "다른 사람들보다 먼저 초기 빌드와 다가오는 기능을 받으세요.",
|
||||
"betaProgramTitle": "미리보기 채널",
|
||||
"description": "VidBee는 Electron으로 구축되고 yt-dlp로 구동되는 무료 오픈소스 다운로더입니다.",
|
||||
"downloadingUpdate": "업데이트 다운로드 중",
|
||||
"followAuthorActions": {
|
||||
"follow": "@nexmoex 팔로우"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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": "이 페이지를 떠나지 않고 업데이트 설정을 조정하세요.",
|
||||
@@ -62,13 +76,7 @@
|
||||
"sourceCode": "소스 코드 사용 가능",
|
||||
"title": "정보",
|
||||
"version": "버전",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "최신: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "새 버전 사용 가능",
|
||||
"uptodate": "최신 버전 사용 중",
|
||||
"error": "최신 버전을 가져올 수 없음"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "다운로드 완료 시 앱 닫기",
|
||||
@@ -122,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": "소스 URL",
|
||||
"videoCodec": "비디오 코덱",
|
||||
"views": "조회수",
|
||||
"width": "너비"
|
||||
},
|
||||
"moreOptions": "더 많은 옵션",
|
||||
"noActiveDownloads": "활성 다운로드 없음",
|
||||
"noAudio": "오디오 없음",
|
||||
@@ -134,6 +170,7 @@
|
||||
"noItems": "항목을 찾을 수 없음",
|
||||
"oneClickDownload": "원클릭 다운로드",
|
||||
"oneClickDownloadDescription": "확인 없이 기본 설정으로 직접 다운로드",
|
||||
"oneClickDownloadEnabled": "원클릭 다운로드가 활성화되었습니다. \n다운로드는 기본 설정으로 바로 시작됩니다.",
|
||||
"oneClickDownloadNow": "지금 다운로드",
|
||||
"oneClickDownloadStarted": "기본 설정으로 다운로드 시작됨",
|
||||
"paste": "붙여넣기",
|
||||
@@ -145,6 +182,8 @@
|
||||
"selectAudioFormat": "오디오 형식 선택",
|
||||
"selectFormat": "형식 선택",
|
||||
"selectVideoFormat": "비디오 형식 선택",
|
||||
"startDownload": "다운로드 시작",
|
||||
"showDetails": "세부정보 표시",
|
||||
"singleVideo": "단일 비디오",
|
||||
"speed": "속도",
|
||||
"title": "제목",
|
||||
@@ -209,6 +248,8 @@
|
||||
"download": "다운로드",
|
||||
"playlist": "재생목록 다운로드",
|
||||
"preferences": "환경설정",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "구독",
|
||||
"supportedSites": "지원되는 사이트",
|
||||
"theme": "테마:"
|
||||
},
|
||||
@@ -226,6 +267,8 @@
|
||||
"videoCopied": "비디오가 클립보드에 복사됨"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "재생목록",
|
||||
"clearPreview": "미리보기 지우기",
|
||||
"comingSoon": "재생목록 다운로드 기능이 곧 출시됩니다!",
|
||||
"completed": "재생목록 다운로드됨",
|
||||
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
||||
@@ -240,12 +283,27 @@
|
||||
"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": "재생목록 다운로드"
|
||||
"title": "재생목록 다운로드",
|
||||
"totalVideos": "총 동영상 수: {{count}}",
|
||||
"untitled": "제목 없는 재생목록"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "정보",
|
||||
@@ -261,16 +319,31 @@
|
||||
"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": "최대 동시 다운로드 수",
|
||||
@@ -289,6 +362,7 @@
|
||||
"normal": "보통",
|
||||
"worst": "최악"
|
||||
},
|
||||
"openLinkError": "링크를 열지 못했습니다.",
|
||||
"proxy": "프록시",
|
||||
"proxyDescription": "네트워크 요청용 프록시 서버",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -296,6 +370,10 @@
|
||||
"selectPath": "선택",
|
||||
"showMoreFormats": "더 많은 형식 옵션 표시",
|
||||
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
|
||||
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
|
||||
},
|
||||
"system": "시스템",
|
||||
"theme": "테마",
|
||||
"themeDescription": "VidBee용 라이트, 다크 또는 시스템 테마 선택",
|
||||
@@ -390,5 +468,119 @@
|
||||
},
|
||||
"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": "구독"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"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"
|
||||
@@ -14,6 +16,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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.",
|
||||
@@ -62,13 +76,7 @@
|
||||
"sourceCode": "Código fonte disponível",
|
||||
"title": "Sobre",
|
||||
"version": "Versão",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Mais recente: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nova versão disponível",
|
||||
"uptodate": "Você está atualizado",
|
||||
"error": "Não foi possível obter a versão mais recente"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fechar aplicativo quando download terminar",
|
||||
@@ -122,11 +130,39 @@
|
||||
"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",
|
||||
@@ -134,6 +170,7 @@
|
||||
"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",
|
||||
@@ -145,6 +182,8 @@
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"startDownload": "Iniciar download",
|
||||
"showDetails": "Mostrar detalhes",
|
||||
"singleVideo": "Vídeo Único",
|
||||
"speed": "Velocidade",
|
||||
"title": "Título",
|
||||
@@ -209,6 +248,8 @@
|
||||
"download": "Download",
|
||||
"playlist": "Baixar Playlist",
|
||||
"preferences": "Preferências",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Assinaturas",
|
||||
"supportedSites": "Sites Suportados",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
@@ -226,6 +267,8 @@
|
||||
"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",
|
||||
@@ -240,12 +283,27 @@
|
||||
"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"
|
||||
"title": "Baixar Playlist",
|
||||
"totalVideos": "Total de vídeos: {{count}}",
|
||||
"untitled": "Playlist sem título"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Sobre",
|
||||
@@ -261,16 +319,31 @@
|
||||
"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",
|
||||
@@ -289,6 +362,7 @@
|
||||
"normal": "Normal",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"openLinkError": "Falha ao abrir o link",
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Servidor proxy para requisições de rede",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -296,6 +370,10 @@
|
||||
"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",
|
||||
@@ -390,5 +468,119 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
586
src/renderer/src/locales/ru.json
Normal file
586
src/renderer/src/locales/ru.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"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": "Выбрать формат видео",
|
||||
"startDownload": "Начать загрузку",
|
||||
"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": "Просмотреть все поддерживаемые сайты"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"download": "下載",
|
||||
"email": "電子郵件",
|
||||
"feedback": "意見回饋",
|
||||
"goToDownload": "前往下載頁面",
|
||||
"openRepo": "開啟 GitHub 儲存庫",
|
||||
"view": "檢視",
|
||||
"visit": "造訪"
|
||||
@@ -14,6 +16,7 @@
|
||||
"betaProgramDescription": "搶先獲得預覽版和即將發布的新功能。",
|
||||
"betaProgramTitle": "預覽通道",
|
||||
"description": "VidBee 是一個基於 Node.js 和 Electron 構建的自由開源應用,使用 yt-dlp 來完成下載。",
|
||||
"downloadingUpdate": "正在下載更新",
|
||||
"followAuthorActions": {
|
||||
"follow": "關注 @nexmoex"
|
||||
},
|
||||
@@ -33,10 +36,15 @@
|
||||
"downloadError": "下載更新失敗",
|
||||
"downloadStarted": "開始下載...",
|
||||
"downloadUpdate": "下載並安裝更新 {{version}}?",
|
||||
"manualDownloadAction": "立即下載",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"restartNowAction": "立即重新啟動",
|
||||
"restartToUpdate": "立即重新啟動以安裝更新?",
|
||||
"unknownErrorFallback": "未知錯誤",
|
||||
"updateAvailable": "發現新版本:{{version}}",
|
||||
"updateAvailableMessage": "新版本 {{version}} 已推出。請從官方網站下載。",
|
||||
"updateDownloaded": "更新已下載,重新啟動以安裝",
|
||||
"updateDownloadedVersion": "下載更新 {{version}},重新啟動安裝",
|
||||
"updateError": "檢查更新失敗:{{error}}"
|
||||
},
|
||||
"preferencesDescription": "無需離開此頁即可調整更新設定。",
|
||||
@@ -122,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": "無音訊",
|
||||
@@ -134,6 +170,7 @@
|
||||
"noItems": "未找到項目",
|
||||
"oneClickDownload": "一鍵下載",
|
||||
"oneClickDownloadDescription": "使用預設設定直接下載,無需確認",
|
||||
"oneClickDownloadEnabled": "已啟用一鍵下載。下載將直接以默認設置開始。",
|
||||
"oneClickDownloadNow": "立即下載",
|
||||
"oneClickDownloadStarted": "已使用預設設定開始下載",
|
||||
"paste": "貼上",
|
||||
@@ -143,8 +180,11 @@
|
||||
"processing": "處理中",
|
||||
"progress": "進度",
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectDownloadType": "選擇下載類型",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"startDownload": "開始下載",
|
||||
"showDetails": "顯示詳情",
|
||||
"singleVideo": "單個影片",
|
||||
"speed": "速度",
|
||||
"title": "標題",
|
||||
@@ -209,6 +249,8 @@
|
||||
"download": "下載",
|
||||
"playlist": "下載播放清單",
|
||||
"preferences": "偏好設定",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "訂閱",
|
||||
"supportedSites": "支援的網站",
|
||||
"theme": "主題:"
|
||||
},
|
||||
@@ -226,6 +268,7 @@
|
||||
"videoCopied": "影片已複製到剪貼簿"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "播放列表",
|
||||
"clearPreview": "清晰預覽",
|
||||
"comingSoon": "播放清單下載功能即將推出!",
|
||||
"completed": "播放清單已下載",
|
||||
@@ -277,6 +320,7 @@
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"clearConfigFile": "清除",
|
||||
"clearCookiesFile": "清除",
|
||||
"configFile": "使用設定檔",
|
||||
"configFileDescription": "yt-dlp 的自訂設定檔",
|
||||
@@ -291,9 +335,16 @@
|
||||
"directorySelectError": "選擇目錄失敗",
|
||||
"downloadPath": "下載位置",
|
||||
"downloadPathDescription": "選擇儲存下載檔案的位置",
|
||||
"enableAnalytics": "幫助改進 VidBee",
|
||||
"enableAnalyticsDescription": "共享匿名使用數據,幫助我們了解應用程序的使用情況並確定改進的優先順序。",
|
||||
"fileSelectError": "選擇檔案失敗",
|
||||
"general": "一般",
|
||||
"hideDockIcon": "隱藏 Dock 圖標",
|
||||
"hideDockIconDescription": "從 macOS Dock 中刪除 VidBee。使用菜單欄或託盤圖標重新打開應用程序。",
|
||||
"language": "語言",
|
||||
"launchAtLogin": "啟動時啟動",
|
||||
"launchAtLoginDescription": "登錄計算機後自動打開 VidBee。",
|
||||
"launchAtLoginUnsupported": "自動啟動僅適用於 macOS 和 Windows。",
|
||||
"light": "淺色",
|
||||
"maxConcurrentDownloads": "最大活動下載數",
|
||||
"maxConcurrentDownloadsDescription": "最大同時下載數量",
|
||||
@@ -320,6 +371,10 @@
|
||||
"selectPath": "選擇",
|
||||
"showMoreFormats": "顯示更多格式選項",
|
||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
|
||||
"intervalDescription": "VidBee 檢查每個訂閱源的頻率(1-24 小時)。"
|
||||
},
|
||||
"system": "系統",
|
||||
"theme": "主題",
|
||||
"themeDescription": "為 VidBee 選擇淺色、深色或系統主題",
|
||||
@@ -414,5 +469,119 @@
|
||||
},
|
||||
"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": "訂閱"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
"download": "下载",
|
||||
"email": "邮件",
|
||||
"feedback": "反馈",
|
||||
"goToDownload": "前往下载页面",
|
||||
"openRepo": "打开 GitHub 仓库",
|
||||
"view": "查看",
|
||||
"visit": "访问"
|
||||
@@ -14,6 +16,7 @@
|
||||
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
|
||||
"betaProgramTitle": "预览通道",
|
||||
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
|
||||
"downloadingUpdate": "正在下载更新",
|
||||
"followAuthorActions": {
|
||||
"follow": "关注 @nexmoex"
|
||||
},
|
||||
@@ -22,15 +25,26 @@
|
||||
"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": "无需离开此页即可调整更新设置。",
|
||||
@@ -51,16 +65,18 @@
|
||||
},
|
||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||
"resourcesTitle": "资源",
|
||||
"shareActions": {
|
||||
"copy": "复制链接",
|
||||
"facebook": "在脸书上分享",
|
||||
"twitter": "在 X 上分享(推特)"
|
||||
},
|
||||
"shareDescription": "一键与您的社区分享 VidBee。",
|
||||
"shareSupport": "向您的朋友推荐VidBee,以支持我们的成长和更新。",
|
||||
"shareTitle": "传播这个词",
|
||||
"sourceCode": "源代码已开放",
|
||||
"title": "关于",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"uptodate": "您已是最新版本",
|
||||
"error": "无法获取最新版本"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "下载完成后关闭应用",
|
||||
@@ -114,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": "无音频",
|
||||
@@ -126,6 +170,7 @@
|
||||
"noItems": "未找到项目",
|
||||
"oneClickDownload": "一键下载",
|
||||
"oneClickDownloadDescription": "使用默认设置直接下载,无需确认",
|
||||
"oneClickDownloadEnabled": "已启用一键下载。\n下载将直接以默认设置开始。",
|
||||
"oneClickDownloadNow": "立即下载",
|
||||
"oneClickDownloadStarted": "已使用默认设置开始下载",
|
||||
"paste": "粘贴",
|
||||
@@ -135,8 +180,11 @@
|
||||
"processing": "处理中",
|
||||
"progress": "进度",
|
||||
"selectAudioFormat": "选择音频格式",
|
||||
"selectDownloadType": "选择下载类型",
|
||||
"selectFormat": "选择格式",
|
||||
"selectVideoFormat": "选择视频格式",
|
||||
"startDownload": "开始下载",
|
||||
"showDetails": "显示详情",
|
||||
"singleVideo": "单个视频",
|
||||
"speed": "速度",
|
||||
"title": "标题",
|
||||
@@ -163,6 +211,7 @@
|
||||
"clearCancelled": "清除已取消",
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除错误",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyUrl": "复制链接",
|
||||
"date": "日期",
|
||||
"description": "查看并管理下载历史",
|
||||
@@ -200,6 +249,8 @@
|
||||
"download": "下载",
|
||||
"playlist": "下载播放列表",
|
||||
"preferences": "偏好设置",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "订阅",
|
||||
"supportedSites": "支持的网站",
|
||||
"theme": "主题:"
|
||||
},
|
||||
@@ -213,9 +264,12 @@
|
||||
"openFolderFailed": "打开文件夹失败",
|
||||
"removeFailed": "移除项目失败",
|
||||
"settingsSaved": "设置已保存",
|
||||
"urlCopied": "链接已复制到剪贴板"
|
||||
"urlCopied": "链接已复制到剪贴板",
|
||||
"videoCopied": "视频已复制到剪贴板"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "播放列表",
|
||||
"clearPreview": "清晰预览",
|
||||
"comingSoon": "播放列表下载功能即将推出!",
|
||||
"completed": "播放列表已下载",
|
||||
"description": "下载 YouTube 播放列表或频道中的全部视频",
|
||||
@@ -230,12 +284,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": "关于",
|
||||
@@ -251,16 +320,31 @@
|
||||
"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": "语言",
|
||||
"launchAtLogin": "启动时启动",
|
||||
"launchAtLoginDescription": "登录计算机后自动打开 VidBee。",
|
||||
"launchAtLoginUnsupported": "自动启动仅适用于 macOS 和 Windows。",
|
||||
"light": "浅色",
|
||||
"maxConcurrentDownloads": "最大活动下载数",
|
||||
"maxConcurrentDownloadsDescription": "最大同时下载数量",
|
||||
@@ -279,6 +363,7 @@
|
||||
"normal": "标准",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "无法打开链接",
|
||||
"proxy": "代理",
|
||||
"proxyDescription": "网络请求的代理服务器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
@@ -286,6 +371,10 @@
|
||||
"selectPath": "选择",
|
||||
"showMoreFormats": "显示更多格式选项",
|
||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
|
||||
"intervalDescription": "VidBee 检查每个订阅源的频率(1-24 小时)。"
|
||||
},
|
||||
"system": "系统",
|
||||
"theme": "主题",
|
||||
"themeDescription": "为 VidBee 选择浅色、深色或系统主题",
|
||||
@@ -380,5 +469,119 @@
|
||||
},
|
||||
"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": "订阅"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -16,16 +17,14 @@ import {
|
||||
FileText,
|
||||
Github,
|
||||
Link as LinkIcon,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Twitter
|
||||
} from 'lucide-react'
|
||||
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 {
|
||||
@@ -48,6 +47,7 @@ export function About() {
|
||||
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://vidbee.org'
|
||||
|
||||
@@ -72,6 +72,49 @@ 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]
|
||||
@@ -87,12 +130,7 @@ export function About() {
|
||||
|
||||
if (result.available) {
|
||||
// The update will be downloaded automatically because autoDownload is enabled
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
@@ -127,12 +165,7 @@ export function About() {
|
||||
const result = await ipcServices.update.checkForUpdates()
|
||||
|
||||
if (result.available) {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
@@ -161,7 +194,7 @@ export function About() {
|
||||
|
||||
const shareLinks = useMemo(() => {
|
||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||
const encodedText = encodeURIComponent(t('about.description'))
|
||||
const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)
|
||||
|
||||
return {
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
@@ -227,25 +260,18 @@ export function About() {
|
||||
href: 'https://github.com/nexmoe/VidBee/releases'
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.feedback'),
|
||||
description: t('about.resources.feedbackDescription'),
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
description: t('about.resources.githubIssuesDescription'),
|
||||
actionLabel: t('about.actions.feedback'),
|
||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
label: t('about.resources.license'),
|
||||
description: t('about.resources.licenseDescription'),
|
||||
actionLabel: t('about.actions.view'),
|
||||
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
label: t('about.resources.contact'),
|
||||
description: t('about.resources.contactDescription'),
|
||||
actionLabel: t('about.actions.email'),
|
||||
href: 'mailto:nexmoex@gmail.com'
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
description: t('about.resources.discordDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
],
|
||||
[t]
|
||||
@@ -281,6 +307,19 @@ export function About() {
|
||||
</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">
|
||||
@@ -367,10 +406,6 @@ export function About() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col divide-y">
|
||||
{aboutResources.map((resource) => {
|
||||
|
||||
@@ -1,126 +1,4 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
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, 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 {
|
||||
addDownloadAtom,
|
||||
addHistoryRecordAtom,
|
||||
removeDownloadAtom,
|
||||
updateDownloadAtom
|
||||
} from '../store/downloads'
|
||||
import { loadSettingsAtom, settingsAtom } from '../store/settings'
|
||||
import {
|
||||
currentVideoInfoAtom,
|
||||
fetchVideoInfoAtom,
|
||||
videoInfoErrorAtom,
|
||||
videoInfoLoadingAtom
|
||||
} from '../store/video'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
bad: 128,
|
||||
worst: 96
|
||||
}
|
||||
|
||||
const dedupe = (candidates: Array<string | undefined>): string[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (seen.has(candidate)) continue
|
||||
seen.add(candidate)
|
||||
result.push(candidate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
||||
settings.oneClickQuality ?? 'best'
|
||||
|
||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||
if (preset === 'worst') {
|
||||
return ['worstaudio']
|
||||
}
|
||||
|
||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||
// 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') {
|
||||
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
|
||||
return 'worstvideo+worstaudio'
|
||||
}
|
||||
|
||||
const maxHeight = qualityPresetToVideoHeight[preset]
|
||||
const videoCandidates = dedupe([
|
||||
maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,
|
||||
'bestvideo'
|
||||
])
|
||||
|
||||
const audioSelectors = buildAudioSelectors(preset)
|
||||
const combinations: string[] = []
|
||||
|
||||
for (const video of videoCandidates) {
|
||||
for (const audio of audioSelectors) {
|
||||
combinations.push(`${video}+${audio}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioSelectors.includes('none')) {
|
||||
for (const video of videoCandidates) {
|
||||
combinations.push(video)
|
||||
}
|
||||
} else {
|
||||
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
|
||||
combinations.push('bestvideo+bestaudio')
|
||||
}
|
||||
|
||||
return dedupe(combinations).join('/')
|
||||
}
|
||||
|
||||
const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
||||
return selectors.join('/')
|
||||
}
|
||||
|
||||
interface HomeProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
@@ -128,673 +6,18 @@ interface HomeProps {
|
||||
}
|
||||
|
||||
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
||||
const { t } = useTranslation()
|
||||
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
|
||||
const [loading] = useAtom(videoInfoLoadingAtom)
|
||||
const [error] = useAtom(videoInfoErrorAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inlinePreviewSites = popularSites
|
||||
.slice(0, 3)
|
||||
.map((site) => t(`sites.popular.${site.id}.label`))
|
||||
.join(', ')
|
||||
|
||||
// Playlist states
|
||||
const playlistUrlId = useId()
|
||||
const downloadTypeId = useId()
|
||||
const [playlistUrl, setPlaylistUrl] = useState('')
|
||||
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) => {
|
||||
try {
|
||||
const historyItem = await ipcServices.history.getHistoryById(id)
|
||||
if (historyItem) {
|
||||
addHistoryRecord(historyItem)
|
||||
removeDownload(id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync history item:', error)
|
||||
}
|
||||
},
|
||||
[addHistoryRecord, removeDownload]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Load settings on mount
|
||||
loadSettings()
|
||||
|
||||
// Listen for download events from main process
|
||||
ipcEvents.on('download:started', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download started:', id)
|
||||
updateDownload({ id, changes: { status: 'downloading' } })
|
||||
})
|
||||
|
||||
ipcEvents.on('download:progress', (...args: unknown[]) => {
|
||||
const data = args[0] as { id: string; progress: unknown }
|
||||
console.log('Download progress:', data)
|
||||
const progress = data.progress as {
|
||||
percent: number
|
||||
currentSpeed?: string
|
||||
eta?: string
|
||||
downloaded?: string
|
||||
total?: string
|
||||
}
|
||||
updateDownload({
|
||||
id: data.id,
|
||||
changes: {
|
||||
progress: {
|
||||
percent: progress.percent || 0,
|
||||
currentSpeed: progress.currentSpeed || '',
|
||||
eta: progress.eta || '',
|
||||
downloaded: progress.downloaded || '',
|
||||
total: progress.total || ''
|
||||
},
|
||||
speed: progress.currentSpeed || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcEvents.on('download:completed', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download completed:', id)
|
||||
updateDownload({ id, changes: { status: 'completed' } })
|
||||
toast.success(t('notifications.downloadCompleted'))
|
||||
void syncHistoryItem(id)
|
||||
})
|
||||
|
||||
ipcEvents.on('download:error', (...args: unknown[]) => {
|
||||
const data = args[0] as { id: string; error: string }
|
||||
console.error('Download error:', data)
|
||||
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
void syncHistoryItem(data.id)
|
||||
})
|
||||
|
||||
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download cancelled:', id)
|
||||
updateDownload({ id, changes: { status: 'cancelled' } })
|
||||
void syncHistoryItem(id)
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Note: Event listeners are automatically cleaned up when the component unmounts
|
||||
// The removeListener calls are not needed as the event system handles cleanup
|
||||
}
|
||||
}, [loadSettings, syncHistoryItem, t, updateDownload])
|
||||
|
||||
const handlePasteUrl = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (!text.trim()) {
|
||||
toast.error(t('errors.clipboardEmpty'))
|
||||
return
|
||||
}
|
||||
setUrl(text.trim())
|
||||
inputRef.current?.focus()
|
||||
} catch (error) {
|
||||
console.error('Failed to paste URL:', error)
|
||||
toast.error(t('errors.pasteFromClipboard'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleFetchVideo = useCallback(async () => {
|
||||
if (!url.trim()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
await fetchVideoInfo(url.trim())
|
||||
}, [url, fetchVideoInfo, t])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleFetchVideo()
|
||||
}
|
||||
},
|
||||
[handleFetchVideo]
|
||||
)
|
||||
|
||||
const handleOneClickDownload = useCallback(async () => {
|
||||
if (!url.trim()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
// Create initial download item with placeholder info
|
||||
const trimmedUrl = url.trim()
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: trimmedUrl,
|
||||
title: t('download.fetchingVideoInfo'),
|
||||
type: settings.oneClickDownloadType,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
const options = {
|
||||
url: trimmedUrl,
|
||||
type: settings.oneClickDownloadType,
|
||||
format:
|
||||
settings.oneClickDownloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
}
|
||||
|
||||
addDownload(downloadItem)
|
||||
|
||||
try {
|
||||
// Start download immediately
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
// Fetch video info in parallel to update the download item
|
||||
try {
|
||||
const videoInfo = await ipcServices.download.getVideoInfo(url.trim())
|
||||
|
||||
// Update the download item in the renderer state
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
title: videoInfo.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
// Extract additional metadata if available
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
// Also update the download info in the main process queue
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: videoInfo.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
|
||||
// Show a subtle notification that video info was updated
|
||||
toast.success(t('download.videoInfoUpdated'))
|
||||
} catch (infoError) {
|
||||
console.warn('Failed to fetch video info for one-click download:', infoError)
|
||||
// Keep the placeholder title if video info fetch fails
|
||||
// Update the title to indicate info fetch failed
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
title: t('download.infoUnavailable'),
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
// Also update the main process queue
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: t('download.infoUnavailable'),
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(t('download.oneClickDownloadStarted'))
|
||||
setUrl('') // Clear the URL after starting download
|
||||
} catch (error) {
|
||||
console.error('Failed to start one-click download:', error)
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
}, [url, settings, addDownload, updateDownload, t])
|
||||
|
||||
// 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
|
||||
}
|
||||
const trimmed = text.trim()
|
||||
setPlaylistUrl(trimmed)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to paste URL:', error)
|
||||
toast.error(t('errors.pasteFromClipboard'))
|
||||
}
|
||||
}, [playlistBusy, t])
|
||||
|
||||
const handleClearPlaylistPreview = useCallback(() => {
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}, [])
|
||||
|
||||
const handlePreviewPlaylist = useCallback(async () => {
|
||||
if (!playlistUrl.trim()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistPreviewLoading(true)
|
||||
try {
|
||||
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])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const format =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
const result = await ipcServices.download.startPlaylistDownload({
|
||||
url: trimmedUrl,
|
||||
type: downloadType,
|
||||
format,
|
||||
startIndex: range.start,
|
||||
endIndex: range.end
|
||||
})
|
||||
|
||||
if (result.totalCount === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
const baseCreatedAt = Date.now()
|
||||
result.entries.forEach((entry, index) => {
|
||||
const downloadItem = {
|
||||
id: entry.downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title || t('download.fetchingVideoInfo'),
|
||||
type: downloadType,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt: baseCreatedAt + index,
|
||||
playlistId: result.groupId,
|
||||
playlistTitle: result.playlistTitle,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: result.totalCount
|
||||
}
|
||||
addDownload(downloadItem)
|
||||
})
|
||||
|
||||
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to start playlist download:', error)
|
||||
toast.error(t('playlist.downloadFailed'))
|
||||
} finally {
|
||||
setPlaylistDownloadLoading(false)
|
||||
}
|
||||
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
|
||||
|
||||
// Auto-focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg bg-card px-4 py-3 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>
|
||||
)}
|
||||
</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)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
</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-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{playlistPreviewError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory />
|
||||
<div className="w-full h-full flex flex-col min-h-0">
|
||||
<div
|
||||
className="container mx-auto max-w-7xl p-6 w-full h-full flex flex-col min-h-0"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ 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()
|
||||
@@ -52,6 +60,8 @@ export function Settings() {
|
||||
fetchPlatform()
|
||||
}, [])
|
||||
|
||||
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
@@ -258,6 +268,46 @@ 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.launchAtLogin')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{autoLaunchSupported
|
||||
? t('settings.launchAtLoginDescription')
|
||||
: t('settings.launchAtLoginUnsupported')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.launchAtLogin}
|
||||
onCheckedChange={(value) => handleSettingChange('launchAtLogin', value)}
|
||||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-2">
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
@@ -272,27 +322,35 @@ export function Settings() {
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-2">
|
||||
{platform === 'darwin' && (
|
||||
<ItemGroup>
|
||||
<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>
|
||||
</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>
|
||||
@@ -349,6 +407,13 @@ export function Settings() {
|
||||
<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>
|
||||
@@ -371,6 +436,9 @@ export function Settings() {
|
||||
<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>
|
||||
@@ -423,6 +491,21 @@ export function Settings() {
|
||||
</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>
|
||||
|
||||
665
src/renderer/src/pages/Subscriptions.tsx
Normal file
665
src/renderer/src/pages/Subscriptions.tsx
Normal 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-2xl 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-2xl px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||
variant="ghost"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{t('subscriptions.add.title')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
|
||||
|
||||
export function SupportedSites() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-5xl p-6 space-y-6" style={{ maxWidth: '100%' }}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('sites.pageTitle')}</CardTitle>
|
||||
<CardDescription>{t('sites.pageDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t('sites.pageIntro')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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) => {
|
||||
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} className="rounded-md border border-border px-6 py-5">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hasDescription ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('sites.moreTitle')}</CardTitle>
|
||||
<CardDescription>{t('sites.moreDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<a href={referenceUrl} target="_blank" rel="noreferrer">
|
||||
{t('sites.openFullList')}
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type DownloadRecord = DownloadItem & {
|
||||
entryType: 'active' | 'history'
|
||||
downloadedAt?: number
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
}
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
@@ -27,9 +28,6 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
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,
|
||||
@@ -43,6 +41,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
savedFileName: item.savedFileName,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
@@ -97,6 +96,31 @@ export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
|
||||
set(downloadRecordsAtom, downloads)
|
||||
})
|
||||
|
||||
export const removeHistoryRecordsAtom = atom(null, (get, set, ids: string[]) => {
|
||||
if (!ids || ids.length === 0) {
|
||||
return
|
||||
}
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
const uniqueIds = Array.from(new Set(ids))
|
||||
uniqueIds.forEach((id) => {
|
||||
downloads.delete(recordKey('history', id))
|
||||
})
|
||||
set(downloadRecordsAtom, downloads)
|
||||
})
|
||||
|
||||
export const removeHistoryRecordsByPlaylistAtom = atom(null, (get, set, playlistId: string) => {
|
||||
if (!playlistId) {
|
||||
return
|
||||
}
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
for (const [key, item] of downloads.entries()) {
|
||||
if (item.entryType === 'history' && item.playlistId === playlistId) {
|
||||
downloads.delete(key)
|
||||
}
|
||||
}
|
||||
set(downloadRecordsAtom, downloads)
|
||||
})
|
||||
|
||||
export const clearHistoryRecordsAtom = atom(null, (get, set) => {
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
for (const [key, item] of downloads.entries()) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user